C---TBB-并行编程教程-全-
C++ TBB 并行编程教程(全)
一、直接说:“你好,TBB!”
线程构建模块(TBB)库在首次发布 10 年后,已经成为并行编程中使用最广泛的 C++ 库之一。虽然它保留了其核心理念和功能,但随着新的机遇和挑战的出现,它将继续扩展以应对这些机遇和挑战。
在这一章中,我们讨论了 TBB 的动机,提供了其主要组件的简要概述,描述了如何获得该库,然后直接跳到几个简单的例子。
为什么要线程化构建模块?
并行编程有着悠久的历史,可以追溯到 20 世纪 50 年代甚至更久。几十年来,科学家们一直在为超级计算机开发大规模并行模拟,企业也一直在为大型多处理器大型机开发企业应用程序。但是大约 10 年前,第一批用于台式机和笔记本电脑的多核芯片开始进入市场。这改变了游戏规则。
第一批多核台式机和笔记本电脑系统中的处理器数量很少——只有两个内核——但必须成为并行程序员的开发人员数量巨大。如果多核处理器成为主流,并行编程也必须成为主流,特别是对于关心性能的开发人员。
TBB 库于 2006 年 9 月首次发布,旨在应对主流并行编程的独特挑战。它现在的目标,也是 10 年前首次推出时的目标,是为开发人员提供一种简单而强大的方法来构建应用程序,随着具有不同架构和更多内核的新平台的推出,这些应用程序将继续扩展。随着主流处理器中的内核数量从 2006 年的两个增加到 2018 年的 64 个以上,这种“面向未来”的做法取得了成效!
为了实现这一目标,即让并行应用不受处理内核数量和能力变化的影响,TBB 背后的关键理念是让开发人员能够轻松地在其应用中表达并行性,同时限制他们对这种并行性到底层硬件的映射的控制。对于一些有经验的并行程序员来说,这种哲学似乎是违反直觉的。如果我们认为并行编程必须通过对系统的裸机编程,并手动调整和优化应用程序以挤出最后一点性能来不惜一切代价获得最高性能,那么 TBB 可能不适合我们。相反,TBB 库是为那些希望编写在今天的平台上获得高性能的应用程序,但又愿意牺牲一点性能来确保他们的应用程序在未来的系统上继续良好运行的开发人员准备的。
为了实现这一目标,TBB 中的接口让我们能够表达应用中的并行性,同时为库提供灵活性,以便它可以有效地将这种并行性映射到当前和未来的平台,并在运行时使其适应系统资源的动态变化。
性能:开销小,对 C++ 好处大
我们无意对性能损失小题大做,也不想否认这一点。对于以“Fortran”风格编写的简单 C++ 代码,具有单层平衡良好的并行循环,可能根本不需要 TBB 的动态特性。然而,这种编码风格的局限性是 TBB 存在的一个重要因素。TBB 旨在高效支持嵌套、并发和顺序的并行组合,并将这种并行动态映射到目标平台。使用像 TBB 这样的可组合库,开发人员可以通过组合包含并行性的组件和库来构建应用程序,而不用担心它们会相互干扰。重要的是,TBB 不要求我们限制我们表达的并行性来避免性能问题。对于使用 C++ 的大型复杂的应用程序,TBB 很容易推荐,没有免责声明。
TBB 库经过多年的发展,不仅适应了新的平台,也满足了开发人员的需求,他们希望对库在将并行性映射到硬件时所做的选择有更多的控制。虽然 TBB 1.0 为用户提供的性能控制非常少,但 TBB 2019 提供了相当多的性能控制——例如亲和力控制、工作隔离的构造、可用于将线程固定到内核的钩子等等。TBB 的开发人员努力设计这些控件,在不牺牲可组合性的情况下提供合适的控件级别。
该库提供的接口层次分明——TBB 提供了适合大多数程序员需求的高级模板,专注于常见案例。但它也提供了低级接口,因此我们可以深入研究,并根据需要为我们的特定应用创建定制的解决方案。TBB 拥有两个世界的精华。我们通常依靠库的默认选择来获得出色的性能,但是如果需要的话,我们可以深入研究细节。
在 TBB 和 C++ 中不断发展对并行性的支持
自从最初的 TBB 问世以来,TBB 库和 C++ 语言都有了很大的发展。2006 年的时候,C++ 还没有对并行编程的语言支持,包括标准模板库(STL)在内的很多库都不容易在并行程序中使用,因为它们不是线程安全的。
C++ 语言委员会一直忙于直接向该语言及其附带的标准模板库(STL)添加线程特性。图 1-1 显示了解决并行性的新的和计划中的 C++ 特性。
图 1-1。
C++ 标准中的特性以及一些建议的特性
尽管我们是 TBB 的忠实粉丝,但事实上我们更希望并行所需的所有基础支持都在 C++ 语言本身中。这将允许 TBB 利用一致的基础来构建更高级别的并行抽象。TBB 的最初版本必须解决缺乏 C++ 语言支持的问题,在这个领域,C++ 标准已经有了很大的发展,以填补 TBB 最初别无选择,只能用可移植锁和原子等功能来填补的基本空白。不幸的是,对于 C++ 开发人员来说,该标准仍然缺乏完全支持并行编程所需的特性。幸运的是,对于本书的读者来说,这意味着 TBB 对于 C++ 中有效的线程化仍然是重要的,并且很可能在未来许多年都是重要的。
理解这一点非常重要,我们并不是在抱怨 C++ 标准流程。向语言标准添加特性最好是非常小心地完成,并仔细检查。例如,C++11 标准委员会在内存模型上花费了巨大的精力。这对于并行编程的重要性对于每个基于该标准构建的库来说都是至关重要的。对于语言标准应该包括什么,应该支持什么,也有一些限制。我们相信 TBB 的任务分配系统和流程图系统不会直接成为语言标准的一部分。即使我们错了,这也不是短期内会发生的事情。
针对并行性的最新 C++ 新增功能
如图 1-1 所示,C++11 标准为线程引入了一些底层的、基本的构建模块,包括std::async
、std::future
和std::thread
。它还引入了原子变量、互斥对象和条件变量。这些扩展要求程序员做大量的编码工作来建立更高层次的抽象——但是它们允许我们直接用 C++ 来表达基本的并行性。C++11 标准对于线程来说是一个明显的改进,但是它并没有为我们提供可以轻松编写可移植的、高效的并行代码的高级特性。它也没有为我们提供任务或底层的偷工减料任务调度程序。
C++17 标准引入了一些特性,这些特性将抽象级别提升到了这些低级构件之上,使我们更容易表达并行性,而不必担心每一个低级细节。正如我们在本书后面所讨论的,仍然有一些重要的限制,所以这些特性还没有足够的表达力或性能——在 C++ 标准方面还有很多工作要做。
这些 C++17 新增功能中最相关的是可以与标准模板库(STL)算法一起使用的执行策略。这些策略让我们选择算法是否可以安全地并行化、矢量化、并行化和矢量化,或者它是否需要保留其原始的有序语义。我们称支持这些策略的 STL 实现为并行 STL。
展望未来,未来的 C++ 标准可能会包含更多的并行特性,如可恢复函数、执行器、任务块、并行 for 循环、SIMD 向量类型和 STL 算法的附加执行策略。
线程构建模块(TBB)库
线程构建模块(TBB)库是一个 C++ 库,它有两个关键作用:(1)在 C++ 标准没有充分发展或新功能没有被所有编译器完全支持的情况下,它填补了支持并行性的基本空白;以及(2)它为并行性提供了更高级别的抽象,这超出了 C++ 语言标准可能包含的范围。TBB 包含许多功能,如图 1-2 所示。
图 1-2。
TBB 图书馆的特色
这些特性可以分为两大类:表达并行计算的接口和独立于执行模型的接口。
并行执行接口
当我们使用 TBB 创建并行程序时,我们使用高级接口之一或直接通过任务来表达应用程序中的并行性。我们将在本书后面更详细地讨论任务,但是现在我们可以把 TBB 任务看作是一个轻量级对象,它定义了一个小的计算及其相关数据。作为 TBB 开发者,我们直接或间接地通过预先打包的 TBB 算法,使用任务来表达我们的应用,并且库为我们将这些任务调度到平台的硬件资源上。
值得注意的是,作为开发人员,我们可能想要表达不同种类的并行性。图 1-3 显示了并行应用中最常见的三个并行层。我们应该注意,一些应用程序可能包含所有三层,而其他应用程序可能只包含其中的一层或两层。TBB 最强大的一个方面是,它为这些不同的并行层提供了高级接口,允许我们使用同一个库来利用所有的层。
图 1-3 中所示的消息驱动层捕获并行性,这种并行性被构造为通过显式消息相互通信的相对较大的计算。这一层中的常见模式包括流图、数据流图和依赖图。在 TBB,这些模式通过流程图接口得到支持(在第三章中描述)。
图 1-3 所示的 fork-join 层支持这样的模式:串行计算分支成一组并行任务,然后仅当并行子计算完成时才继续。fork-join 模式的例子包括功能并行(任务并行)、并行循环、并行归约和流水线。TBB 用它的通用并行算法支持这些(在第二章中描述)。
图 1-3。
应用中常见的三个并行层,以及它们如何映射到高级 TBB 并行执行接口
最后,在单指令多数据(SIMD)层,通过对多个数据元素同时应用相同的操作来利用数据并行性。这种类型的并行性通常使用矢量扩展来实现,如 AVX、AVX2 和 AVX-512,它们使用每个处理器内核中可用的矢量单元。所有的 TBB 发行版都有一个并行的 STL 实现(在第四章中描述),它提供了矢量实现,以及其他利用这些扩展的功能。
TBB 为许多常见的并行模式提供了高级接口,但是仍然存在没有高级接口匹配问题的情况。如果是这样的话,我们可以直接使用 TBB 任务来构建我们自己的算法。
TBB 并行执行接口的真正力量来自于将它们混合在一起的能力,这通常被称为“可组合性”我们可以创建在顶层具有流图的应用程序,该流图具有使用嵌套的通用并行算法的节点。反过来,这些嵌套的通用并行算法可以在其主体中使用并行 STL 算法。由于所有这些层所表达的并行性都暴露给了 TBB 库,所以这个库可以以高效且可组合的方式来调度相应的任务,从而充分利用平台的资源。
使 TBB 成为可组合的关键属性之一是它支持宽松的顺序语义。宽松的顺序语义意味着我们使用 TBB 任务表达的并行性实际上只是对库的一个暗示;不能保证任何任务实际上彼此并行执行。这为 TBB 图书馆提供了极大的灵活性,可以根据需要安排任务以提高性能。这种灵活性使该库能够在系统上提供可扩展的性能,无论它们是单核、八核还是 80 核。它还允许库适应平台上的动态负载;例如,如果一个内核超额完成工作,TBB 可以在其他内核上安排更多工作,甚至选择只使用一个内核来执行并行算法。我们将在第九章中详细描述为什么 TBB 被认为是一个可组合的库。
独立于执行模型的接口
与并行执行接口不同,图 1-2 中的第二大组特性完全独立于执行模型和 TBB 任务。这些特性在使用本机线程的应用程序(如pthreads
或WinThreads
)中与在使用 TBB 任务的应用程序中一样有用。
这些特性包括并发容器,这些容器为常见的数据结构(如哈希表、队列和向量)提供线程友好的接口。它们还包括内存分配的特性,如 TBB 可伸缩内存分配器和高速缓存对齐分配器(两者都在第七章中描述)。它们还包括低级功能,如同步原语和线程本地存储。
使用 TBB 的积木
作为开发人员,我们可以挑选 TBB 中对我们的应用程序有用的部分。例如,我们可以只使用可伸缩的内存分配器(在第七章中描述),其他什么都不用。或者,我们可以使用并发容器(在第六章中描述)和一些通用的并行算法(第二章)。当然,我们也可以选择全力以赴,构建一个结合了所有三个高级执行接口的应用程序,并利用 TBB 可伸缩内存分配器和并发容器,以及库中的许多其他功能。
让我们开始吧!
获取线程构建模块(TBB)库
在开始使用 TBB 之前,我们需要获得该库的副本。有几种方法可以做到这一点。在写这本书的时候,这些方法包括
-
点击
www.threadingbuildingblocks.org
或https://software.intel.com/intel-tbb
的链接,直接从英特尔获得 TBB 库的免费版本。有适用于 Windows、Linux 和 macOS 的预编译版本。最新的软件包包括 TBB 库和并行 STL 算法的实现,该算法使用 TBB 进行线程处理。 -
访问
https://github.com/intel/tbb
获得 TBB 图书馆的免费开源版本。TBB 的开源版本绝不是该库的精简版;它包含商业支持版本的所有功能。您可以选择从源代码中检验和构建,也可以单击“发布”下载由英特尔构建和测试的版本。在 GitHub,预构建和测试版本可用于 Windows、Linux、macOS 和 Android。同样,TBB 预建版本的最新包包括 TBB 库和一个使用 TBB 线程的并行 STL 实现。如果你想要并行 STL 的源代码,你需要从https://github.com/intel/parallelstl
单独下载。 -
您可以下载一份英特尔 Parallel Studio XE 工具套件
https://software.intel.com/intel-parallel-studio-xe
。TBB 和使用 TBB 的并行 STL 目前包含在该工具套件的所有版本中,包括最小的 Composer 版本。如果您安装了最新版本的英特尔 C++ 编译器,那么您的系统中可能已经安装了 TBB。
我们让读者选择获得 TBB 的最合适的途径,并遵循相应站点上提供的安装软件包的说明。
获取示例的副本
本书中使用的所有代码示例都可以在 https://github.com/Apress/pro-TBB
获得。在这个库中,每个章节都有目录。许多源文件是根据它们出现的图来命名的,例如ch01/fig_1_04.cpp
包含与本章中的图 1-4 匹配的代码。
写第一句“你好,TBB!”例子
图 1-4 提供了一个小例子,使用一个tbb::parallel_invoke
来评估两个函数,一个打印Hello
,另一个并行打印TBB!
。这个例子很简单,不会从并行化中受益,但是我们可以使用它来确保我们已经正确地设置了使用 TBB 的环境。在图 1-4 中,我们包含了 tbb.h 头来访问 tbb 函数和类,它们都在名称空间 TBB 中。对parallel_invoke
的调用向 TBB 库断言,传递给它的两个函数是相互独立的,在不同的内核或线程上以任何顺序并行执行都是安全的。在这些约束条件下,得到的输出可能首先包含Hello
或TBB!
。我们甚至可以看到,在输出的末尾,两个字符串和两个连续的换行符之间没有换行符,因为每个字符串及其std::endl
的打印不是自动进行的。
图 1-4。
一个你好 TBB 的例子
图 1-5 提供了一个使用并行 STL std::for_each
将一个函数并行应用到一个std::vector
中的两个项目的例子。将一个pstl::execution::par
策略传递给std::for_each
断言,在不同的内核或线程上并行地将所提供的函数应用于解引用范围v.begin(), v.end()).
中的每个迭代器的结果是安全的,就像图 [1-4 一样,运行该示例的输出可能会首先打印任一字符串。
图 1-5。
Hello 并行 STL 示例
在两个图 1-4 和 1-5 中,我们使用 C++ lambda 表达式来指定函数。当使用像 TBB 这样的库来指定作为任务执行的用户代码时,Lambda 表达式非常有用。为了帮助复习 C++ lambda 表达式,我们提供了一个标注框“C++ Lambda 表达式入门”,概述了这一重要的现代 C++ 特性。
C++ Lambda 表达式入门
对 lambda 表达式的支持是在 C++11 中引入的。它们用于创建匿名函数对象(尽管您可以将它们赋给命名变量),这些对象可以从封闭范围中捕获变量。C++ lambda 表达式的基本语法是
- 【捕获清单】[参数)->【ret】**
**在哪里
-
捕获列表是一个逗号分隔的捕获列表。我们通过在捕获列表中列出变量名来按值捕获变量。我们通过引用捕获一个变量,在它前面加上一个&符号,例如,
&v
。我们可以使用this
通过引用来捕获当前对象。也有默认:[=]
用于通过值捕获主体中使用的所有自动变量,通过引用捕获当前对象,[&]
用于通过引用捕获主体中使用的所有自动变量以及当前对象,[]
什么都不捕获。 -
params
是函数参数列表,就像命名函数一样。 -
ret
是返回类型。如果未指定->ret
,则从返回语句中推断出来。 -
body
是函数体。
下一个例子展示了一个 C++ lambda 表达式,它通过值捕获一个变量i
,通过引用捕获另一个变量j
。它还有一个参数k0
和另一个通过引用接收的参数l0
:
运行该示例将产生以下输出:
i == 1
j == 10
k == 100
l == 1000
First call returned 2221
i == 1
j == 20
k == 100
l == 2000
Second call returned 4241
i == 1
j == 40
k == 100
l == 4000
我们可以把 lambda 表达式看作一个函数对象的实例,但是编译器为我们创建了类定义。例如,我们在前面的例子中使用的 lambda 表达式类似于一个类的实例:
无论我们在哪里使用 C++ lambda 表达式,我们都可以用一个函数对象的实例来代替它,就像前面的例子一样。事实上,TBB 库早于 C++11 标准,它的所有接口都需要传入用户定义类的对象实例。C++ lambda 表达式通过消除每次使用 TBB 算法时定义一个类的额外步骤,简化了 TBB 的使用。
构建简单的示例
一旦我们编写了图 1-4 和 1-5 中的例子,我们需要从它们构建可执行文件。构建使用 TBB 的应用程序的指令依赖于操作系统和编译器。但是,一般来说,正确配置环境需要两个必要的步骤。
设置环境的步骤
-
我们必须通知编译器 TBB 头文件和库的位置。如果我们使用并行 STL 接口,我们还必须通知编译器并行 STL 头文件的位置。
-
我们必须配置我们的环境,以便应用程序可以在运行时定位 TBB 库。TBB 是作为动态链接库提供的,这意味着它不是直接嵌入到我们的应用程序中的;相反,应用程序在运行时定位并加载它。并行 STL 接口不需要自己的动态链接库,但是依赖于 TBB 库。
我们现在将简要讨论在 Windows 和 Linux 上完成这些步骤的一些最常见的方法。macOS 的指令类似于 Linux 的指令。TBB 库附带的文档中有更多的案例和更详细的说明。
使用 Microsoft Visual Studio 在 Windows 上构建
如果我们下载 TBB 的商业支持版本或英特尔 Parallel Studio XE 的版本,我们可以在安装时将 TBB 库与微软 Visual Studio 集成,然后从 Visual Studio 使用 TBB 就非常简单了。
创造一个“你好,TBB!”项目,我们在 Visual Studio 中照常创建一个项目,用图 1-4 或图 1-5 中包含的代码添加一个“.cpp
文件,然后转到项目的属性页,遍历到配置属性➤英特尔性能库,将使用 TBB 改为是,如图 1-6 所示。这就完成了步骤 1。Visual Studio 现在会将 TBB 库链接到项目中,因为它具有指向头文件和库的正确路径。这也正确地设置了并行 STL 文件头的路径。
图 1-6。
在 Visual Studio 的项目属性页中设置使用【TBB】到 是
在 Windows 系统上,由应用程序可执行文件在运行时动态加载的 TBB 库是。dll
"文件。为了完成设置环境的第 2 步,我们需要将这些文件的位置添加到 PATH 环境变量中。我们可以通过将路径添加到我们的用户或系统路径变量中来做到这一点。找到这些设置的一个地方是在 Windows 控制面板中,通过遍历系统和安全➤系统➤高级系统设置➤环境变量。关于“.dll
”文件的确切位置,我们可以参考我们的 TBB 安装文档。
注意
对环境中 PATH 变量的更改只有在 Microsoft Visual Studio 重新启动后才会生效。
一旦我们输入了源代码,让使用 TBB 设置为是,并且在我们的 path 变量中有了 TBB 的路径.dll
,我们就可以通过输入 Ctrl-F5 来构建和执行程序。
从终端构建在 Linux 平台上
使用英特尔编译器
使用英特尔 C++ 编译器时,编译过程得到了简化,因为 TBB 库包含在编译器中,它支持一个编译器标志–tbb
,可以在编译过程中为我们正确设置包含和库路径。因此,要使用英特尔 C++ 编译器编译我们的示例,我们只需在编译行添加–tbb
标志。
icpc –std=c++11 -tbb –o fig_1_04 fig_1_04.cpp
icpc –std=c++11 -tbb –o fig_1_05 fig_1_05.cpp
tbbvars
和pstlvars
脚本
如果我们不使用英特尔 C++ 编译器,我们可以使用 TBB 和并行 STL 发行版中包含的脚本来设置我们的环境。这些脚本修改了CPATH
、LIBRARY_PATH
和LD_LIBRARY_PATH
环境变量,以包含构建和运行 TBB 和并行 STL 应用程序所需的目录。当编译器查找#include
文件时,CPATH
变量将额外的目录添加到编译器搜索的目录列表中。LIBRARY_PATH
在编译时查找要链接的库时,将额外的目录添加到编译器搜索的目录列表中。并且LD_LIBRARY_PATH
将额外的目录添加到可执行文件在运行时加载动态库时将搜索的目录列表中。
让我们假设我们的 TBB 安装的根目录是TBB_ROOT
。TBB 库在${TBB_ROOT}/bin
目录中附带了一组脚本,我们可以执行这些脚本来正确地设置环境。我们需要将我们的架构类型[ia32|intel64|mic]
传递给这个脚本。我们还需要在编译时添加一个标志来启用 C++11 特性的使用,比如我们对 lambda 表达式的使用。
尽管所有最近的 TBB 库包中都包含了并行 STL 头文件,但我们需要额外的步骤来将它们添加到我们的环境中。就像 TBB 一样,并行 STL 在${PSTL_ROOT}/bin
目录中附带了一组脚本。PSTL_ROOT
目录通常是TBB_ROOT
目录的兄弟。我们还需要传入我们的架构类型,并启用 C++11 特性来使用并行 STL。
在采用 64 位英特尔处理器的 Linux 平台上构建和执行图 1-4 中的示例的步骤如下
source ${TBB_ROOT}/bin/tbbvars.sh intel64 linux auto_tbbroot
g++ -std=c++11 -o fig_1_04 fig_1_04.cpp -ltbb
./fig_1_04
在采用 64 位英特尔处理器的 Linux 平台上构建和执行图 1-5 中的示例的步骤如下
source ${TBB_ROOT}/bin/tbbvars.sh intel64 linux auto_tbbroot
source ${PSTL_ROOT}/bin/pstlvars.sh intel64 auto_pstlroot
g++ -std=c++11 -o fig_1_05 fig_1_05.cpp -ltbb
./fig_1_05
注意
越来越多的 Linux 发行版包含了 TBB 库的副本。在这些平台上,GCC 编译器可能链接到平台版本的 TBB 库,而不是由tbbvars
脚本添加到 LIBRARY_PATH 的 TBB 库版本。如果我们在使用 TBB 时发现链接问题,这可能就是问题所在。如果是这种情况,我们可以在编译器的命令行中添加一个显式的库路径,以选择特定版本的 TBB 库。
例如:
g++ -L${TBB_ROOT}/lib/intel64/gcc4.7 –ltbb ...
我们可以在g++
命令行中添加–Wl,--verbose
来生成一份报告,报告编译期间被链接的所有库,以帮助诊断这个问题。
虽然我们显示了g++
的命令,但是除了使用的编译器名称之外,英特尔编译器(icpc
)或 LLVM ( clang++
)的命令行是相同的。
不使用tbbvars
脚本或英特尔编译器手动设置变量
有时我们可能不想使用tbbvars
脚本,要么是因为我们想确切地知道正在设置什么变量,要么是因为我们需要与构建系统集成。如果不适合您,请跳过这一部分,除非您真的很想手动操作。
既然您还在阅读本节,让我们看看如何在不使用tbbvars
脚本的情况下在命令行上构建和执行。当用非英特尔编译器编译时,我们没有可用的–tbb
标志,所以我们需要指定 TBB 头文件和共享库的路径。
如果我们的 TBB 安装的根目录是TBB_ROOT,
,那么头文件在${TBB_ROOT}/include
中,共享库文件存储在${TBB_ROOT}/lib/${ARCH}/${GCC_LIB_VERSION},
中,其中ARCH
是系统架构[ia32|intel64|mic]
,而GCC_LIB_VERSION
是与您的 GCC 或 clang 安装兼容的 TBB 库的版本。
TBB 库版本之间的根本区别是它们依赖于 C++ 运行时库中的特性(例如libstdc++
或libc++
)。
通常,为了找到合适的 TBB 版本,我们可以在终端中执行命令gcc –version
。然后,我们选择在${TBB_ROOT}/lib/${ARCH}
中可用的最接近的 GCC 版本,该版本不比我们的 GCC 版本新(即使当我们使用 clang++ 时,这通常也是有效的)。但是由于不同机器的安装可能不同,并且我们可以选择编译器和 C++ 运行时的不同组合,这种简单的方法可能并不总是有效。如果没有,请参考 TBB 文档以获得更多指导。
例如,在安装了 GCC 5.4.0 的系统上,我们用
g++ -std=c++11 -o fig_1_04 fig_1_04.cpp \
–I ${TBB_ROOT}/include \
-L ${TBB_ROOT}/lib/intel64/gcc4.7 –ltbb
而在使用 clang++ 的时候,我们用的是同一个 TBB 版本:
clang++ -std=c++11 -o fig_1_04 fig_1_04.cpp \
-I ${TBB_ROOT}/include \
-L ${TBB_ROOT}/lib/intel64/gcc-4.7 –ltbb
为了编译图 1-5 中的例子,我们还需要添加并行 STL 包含目录的路径:
g++ -std=c++11 -o fig_1_05 fig_1_05.cpp \
–I ${TBB_ROOT}/include \
-I ${PSTL_ROOT}/include \
-L ${TBB_ROOT}/lib/intel64/gcc4.7 –ltbb
不管我们是用英特尔编译器、gcc 还是 clang++ 编译,我们都需要将 TBB 共享库位置添加到我们的LD_LIBRARY_PATH
中,以便在应用程序运行时可以找到它。同样,假设我们的 TBB 安装的根目录是TBB_ROOT,
,我们可以这样设置,例如,用
export LD_LIBRARY_PATH=${TBB_ROOT}/lib/${ARCH}/${GCC_LIB_VERSION}:${LD_LIBRARY_PATH}
一旦我们使用英特尔编译器、gcc 或 clang++ 编译了我们的应用,并根据需要设置了我们的LD_LIBRARY_PATH
,我们就可以从命令行运行应用了:
./fig_1_04
这将产生类似于以下内容的输出
Hello
Parallel STL!
一个更完整的例子
前面几节提供了编写、构建和执行一个简单的 TBB 应用程序和一个简单的并行 STL 应用程序的步骤,每个应用程序都打印几行文本。在这一节中,我们编写了一个更大的示例,它使用图 1-2 中所示的所有三个高级执行接口,可以从并行执行中获益。我们不解释用于创建该示例的算法和特性的所有细节,而是使用该示例来查看可以用 TBB 表达的不同并行层。这个例子显然是人为的。用几个段落解释足够简单,但展示图 1-3 中描述的所有并行层又足够复杂。我们在这里创建的最终多级并行版本应该被视为一个语法演示,而不是如何编写一个最佳 TBB 应用程序的指南。在随后的章节中,我们将更详细地介绍本节中使用的所有特性,并就如何使用它们在更现实的应用中获得更好的性能提供指导。
从串行实现开始
让我们从图 1-7 所示的串行实现开始。本示例对图像矢量中的每个图像应用灰度校正和色调,并将每个结果写入一个文件。突出显示的函数fig_1_7
包含一个 for 循环,通过对每幅图像执行applyGamma
、applyTint
和writeImage
函数来处理矢量的元素。图 1-7 中也提供了这些功能的串行实现。图像表示和一些辅助功能的定义包含在ch01.h
中。在 https://github.com/Apress/threading-building-blocks
可以找到这个头文件,以及这个例子的所有源代码。
图 1-7。
对图像矢量应用灰度校正和色调的示例的串行实现
applyGamma
函数和applyTint
函数在外部 for 循环中遍历图像的行,在内部 for 循环中遍历每行的元素。计算新的像素值并将其分配给输出图像。applyGamma
功能应用伽马校正。applyTint
功能将蓝色调应用于图像。这些函数接收并返回std::shared_ptr
对象以简化内存管理;不熟悉std::shared_ptr
的读者可以参考侧栏讨论“关于智能指针的说明”图 1-8 显示了通过示例代码输入的图像输出示例。
图 1-8。
示例输出:(a)由ch01::makeFractalImage(2000000)
生成的原始图像,(b)经过伽马校正后的图像,以及(c)经过伽马校正和着色后的图像
关于智能指针的一个注释
C/C++ 编程中最具挑战性的部分之一是动态内存管理。当我们使用 new/delete 或 malloc/free 时,我们必须确保正确地匹配它们,以避免内存泄漏和双重释放。C++11 中引入了智能指针,包括unique_ptr
、shared_ptr
和weak_ptr
,以提供自动的、异常安全的内存管理。例如,如果我们通过使用make_shared
分配一个对象,我们会收到一个指向该对象的智能指针。当我们将这个共享指针分配给其他共享指针时,C++ 库会为我们处理引用计数。当没有通过任何智能指针对我们的对象进行未完成的引用时,对象将被自动释放。在本书的大多数例子中,包括图 1-7 ,我们使用智能指针而不是原始指针。使用智能指针,我们不必担心找到所有需要插入或删除的点——我们可以依靠智能指针做正确的事情。
使用流程图添加消息驱动层
使用自上而下的方法,我们可以用一个 TBB 流图来代替图 1-7 中函数fig_1_07
的外部循环,该图通过一组过滤器来传输图像,如图 1-9 所示。我们承认,在这个特殊的例子中,这是我们最做作的选择。在这种情况下,我们可以很容易地使用外部并行循环;或者我们可以将 Gamma 和 Tint 循环嵌套合并在一起。但是出于演示的目的,我们选择用一个单独节点的图来表示,以展示 TBB 如何被用来表示消息驱动的并行性,这是图 1-3 中的顶级并行性。在第三章中,我们将了解更多关于 TBB 流图接口的知识,并发现这种高级的、消息驱动的执行接口的更多自然应用。
图 1-9。
有四个节点的数据流图:(1)获取或生成图像的节点,(2)应用灰度校正的节点,(3)应用色调的节点,以及(4)写出结果图像的节点
通过使用图 1-9 中的数据流图,我们可以将应用于不同图像的不同阶段流水线的执行重叠。例如,当第一个图像 img 0 在gamma
节点完成时,结果被传递到tint
节点,而新图像 img 1 进入gamma
节点。同样,当这个下一步完成时,现在已经通过gamma
和tint
节点的 img 0 被发送到write
节点。同时,img 1 被发送到tint
节点,新的图像 img 2 在gamma
节点开始处理。在每一步,过滤器的执行都是相互独立的,因此这些计算可以分布在不同的内核或线程上。图 1-10 显示了函数fig_1_7
的循环,现在表示为 TBB 流程图。
图 1-10。
使用 TBB 流图代替外部 for 循环
正如我们将在第三章中看到的,构建和执行 TBB 流图需要几个步骤。首先,构建一个图形对象g
。接下来,我们在数据流图中构建代表计算的节点。将图像传输到图的其余部分的节点是一个名为src
的source_node
。计算由名为gamma
、tint
和write
的function_node
对象执行。我们可以认为source_node
是一个没有输入的节点,它继续发送数据,直到没有数据可发送。我们可以把function_node
看作是接收输入并生成输出的函数的包装器。
创建节点后,我们使用边将它们相互连接起来。边表示节点之间的依赖关系或通信通道。因为,在图 1-10 的例子中,我们希望src
节点发送初始图像到gamma
节点,我们从src
节点到gamma
节点.
做一条边,然后从gamma
节点到tint
节点做一条边。同样,我们制作一条从tint
节点到write
节点的边。一旦我们完成了图结构的构建,我们调用src.activate()
来启动source_node
并调用g.wait_for_all()
来等待直到图完成。
当图 1-10 中的应用程序执行时,由src
节点生成的每幅图像都将通过节点管道,如前所述。当一个图像被发送到gamma
节点时,TBB 库创建并调度一个任务,将gamma
节点的主体应用到图像上。当该处理完成时,输出被馈送到tint
节点。同样,TBB 将创建并调度一个任务,在gamma
节点的输出上执行tint
节点的主体。最后,当处理完成时,tint
节点的输出被发送到write
节点。同样,一个任务被创建并被调度来执行节点的主体,在本例中是将图像写入文件。每次执行完src
节点并返回true
时,都会产生一个新的任务来再次执行src
节点的主体。只有在src
节点停止生成新图像并且它已经生成的所有图像已经在写入节点中完成处理之后,wait_for_all
调用才会返回。
使用parallel_for
添加分叉连接层
现在,让我们把注意力转向applyGamma
和applyTint
函数的实现。在图 1-11 中,我们用对tbb::parallel_for
的调用替换了串行实现中的外部i
循环。我们使用一个parallel_for
通用并行算法来并行执行不同的行。一个parallel_for
创建的任务可以在一个平台上的多个处理器内核间扩展。该模式是图 1-3 中分叉连接层的一个例子,在第二章中有更详细的描述。
图 1-11。
添加parallel_for
以并行应用跨行的伽马校正和色调
使用并行 STL 转换添加 SIMD 层
我们可以通过调用并行 STL 函数transform.
来替换内部j
循环,从而进一步优化我们的两个计算内核。transform
算法将函数应用于输入范围内的每个元素,并将结果存储到输出范围内。transform
的参数是(1)执行策略,(2 和 3)元素的输入范围,(4)输出范围的开始,以及(5)应用于输入范围中的每个元素并且其结果存储到输出元素的 lambda 表达式。
在图 1-12 中,我们使用unseq
执行策略来告诉编译器使用 SIMD 版本的转换函数。并行 STL 功能在第四章中有更详细的描述。
图 1-12。
使用std::transform
将 SIMD 并行添加到内部循环中
在图 1-12 中,每个Image::Pixel
对象包含一个具有四个单字节元素的数组,代表该像素的蓝色、绿色、红色和 alpha 值。通过使用unseq
执行策略,一个向量化的循环被用来跨元素行应用函数。这种级别的并行化对应于图 1-3 中的 SIMD 层,并利用 CPU 内核中的矢量单元来执行代码,但不会将计算分散到不同的内核中。
注意
将执行策略传递给并行 STL 算法并不能保证并行执行。库选择比所要求的更严格的执行策略是合法的。因此,检查使用执行策略的影响非常重要——尤其是依赖编译器实现的执行策略!
虽然我们在图 1-7 到图 1-12 中创建的例子有点做作,但它们展示了 TBB 库的并行执行接口的广度和力量。使用单个库,我们表达了消息驱动、fork-join 和 SIMD 并行,将它们组合成一个应用程序。
摘要
在这一章中,我们首先解释了为什么像 TBB 这样的图书馆在今天比 10 年前首次推出时更有意义。然后,我们简要地看了一下库中的主要特性,包括并行执行接口和独立于执行接口的其他特性。我们看到,高级执行接口映射到许多并行应用程序中常见的消息驱动、fork-join 和 SIMD 层。然后,我们讨论了如何获得 TBB 的副本,并通过编写、编译和执行非常简单的示例来验证我们的环境设置是否正确。我们通过构建一个使用所有三个高级执行接口的更完整的例子来结束这一章。
我们现在准备在接下来的几章中介绍并行编程的关键支持:通用并行算法(第二章)、流程图(第三章)、并行 STL(第四章)、同步(第五章)、并发容器(第六章)和可伸缩内存分配(第七章)。
开放存取本章根据知识共享署名-非商业-非专用 4.0 国际许可协议(http://Creative Commons . org/licenses/by-NC-nd/4.0/)的条款进行许可,该协议允许以任何媒体或格式进行任何非商业使用、共享、分发和复制,只要您适当注明原作者和来源,提供知识共享许可协议的链接,并指出您是否修改了许可材料。根据本许可证,您无权共享从本章或其部分内容派生的改编材料。
本章中的图像或其他第三方材料包含在本章的知识共享许可中,除非在材料的信用额度中另有说明。如果材料不包括在本章的知识共享许可中,并且您的预期使用不被法律法规允许或超出了允许的使用范围,您将需要直接从版权所有者处获得许可。**
二、通用并行算法
调度并行循环的最佳方法是什么?我们如何并行处理不支持随机访问迭代器的数据结构?向看起来像管道的应用程序添加并行性的最佳方式是什么?如果 TBB 图书馆只提供任务和任务调度程序,我们将需要自己回答这些问题。幸运的是,我们不需要费力地阅读许多关于这些主题的硕士和博士论文。TBB 图书馆的开发人员已经为我们做了这些肮脏的工作!它们以模板函数和模板类的形式提供了解决这些问题的最佳方法,这是一组被称为 TBB 通用并行算法的功能。这些算法捕获了许多处理模式,这些模式是多线程编程的基石。
注意
TBB 库的开发者历史上一直使用通用并行算法来描述这组特性。所谓的算法,并不是指像矩阵乘法、LU 分解这样的特定计算,甚至是像std::find
这样的东西,而是指常见的执行模式。这本书的一些评论家认为,这些特征因此被更准确地称为模式而不是算法。然而,为了与 TBB 图书馆多年来一直使用的术语保持一致,我们在本书中将这些特性称为通用并行算法。
无论何时应用这些预先写好的算法,我们都应该优先使用它们,而不是编写我们自己的实现。TBB 的开发者已经花了数年时间来测试和改进他们的性能!当然,TBB 库中包含的算法集并没有详尽地涵盖所有可能的场景,但是如果其中一个确实符合我们的处理模式,我们应该使用它。TBB 提供的算法捕获了应用程序中大多数可扩展的并行性。在第八章中,我们将讨论并行编程的设计模式,例如马特森、桑德斯和马辛吉(Addison-Wesley) 的《并行编程的模式》中描述的那些模式,以及我们如何使用 TBB 通用并行算法来实现它们。
如图 2-1 所示,所有的 TBB 通用算法都是从一个执行线程开始的。当线程遇到并行算法时,它会将与该算法相关的工作分散到多个线程中。当所有的工作都完成后,执行会合并到一起,并在最初的单线程上继续执行。
图 2-1。
TBB 并行算法的分叉连接性质
TBB 算法提供了一个强大但相对容易应用的并行模型,因为它们通常可以增量地添加,并且具有所考虑的代码的相当局部的视图。我们可以寻找程序最耗时的区域,添加 TBB 并行算法来加速该区域,然后寻找下一个最耗时的区域,在那里添加并行性,等等。
但是必须明白,TBB 算法并不能保证并行执行!相反,它们只通知库允许并行执行。如果我们从 TBB 的角度来看图 2-1 ,这意味着所有的工作线程都可以参与执行部分计算,只有一部分线程可以参与,或者只有主线程可以参与。像 TBB 那样假设并行性是可选的程序和库被称为具有宽松的顺序语义。
**如果仅使用单线程执行并行程序不会改变程序的语义,则该程序具有顺序语义。正如我们将在本书中多次提到的,由于舍入问题和其他不精确的原因,程序的顺序和并行执行的结果可能并不总是完全匹配。我们通过使用术语宽松序列语义来承认这些潜在的、无意义的差异。虽然 TBB 和 OpenMP API 等模型提供了宽松的顺序语义,但 MPI 等其他模型让我们可以编写具有循环关系的应用,这些关系需要并行执行。正如在第一章中介绍的以及在第九章中更详细描述的,TBB 宽松的顺序语义是它对于编写可组合应用有用的一个重要部分。现在,我们应该记住,本章描述的任何算法都会将工作分散到一个或多个线程上,但不一定是系统中所有可用的线程。
线程构建模块 2019 发行版中可用的算法集如图 2-2 中的表格所示。它们都在名称空间 tbb 中,并且在包含 tbb.h 头文件时可用。本章介绍了粗体算法的基础知识,其他算法将在后面的章节中介绍。我们还提供了一个侧栏Lambda expressions–vs-user-defined classes,解释了虽然我们在本书的示例中几乎只使用 Lambda 表达式将代码传递给 TBB 算法,但是如果需要的话,这些参数几乎总是可以被用户定义的函数对象替换。
图 2-2。
线程构建模块库中的通用算法。本章将更详细地介绍粗体算法。
Lambda 表达式–vs .用户定义的类
由于 TBB 的第一版早于将 lambda 表达式引入语言的 C++11 标准,TBB 泛型算法不需要使用 lambda 表达式。有时,我们可以对 lambda 表达式或函数对象(仿函数)使用相同的接口。在其他情况下,一个算法有两组接口:一组更适合 lambda 表达式,另一组更适合用户定义的对象。
例如,代替
我们可以使用用户定义的类并编写
通常,选择使用 lambda 表达式还是用户定义的对象只是个人喜好的问题。
功能/任务并行性
也许 TBB 库提供的最简单的算法是parallel_invoke
,这个函数允许我们并行执行少至两个函数,或者我们希望指定的任意多个函数:
这个概念的模式名是map——我们将在第八章中详细讨论模式。这种算法/模式所表达的独立性使得它可以很好地扩展,当我们可以应用它时,它是首选的并行方式。我们还将看到parallel_for
,因为循环体必须是独立的,可以用于类似的效果。
对parallel_invoke
可用接口的完整描述可以在附录 b 中找到。如果我们有一组需要调用的函数,并且并行执行调用是安全的,那么我们使用parallel_invoke
。例如,我们可以对两个向量v1
和v2
进行排序,方法是在每个向量上依次调用一个serialQuicksort
:
serialQuicksort(serial_v1.begin(), serial_v1.end());
serialQuicksort(serial_v2.begin(), serial_v2.end());
或者,由于这些调用彼此独立,我们可以使用一个parallel_invoke
来允许 TBB 库创建可以由不同工作线程并行执行的任务,以重叠这两个调用,如图 2-3 所示。
图 2-3。
使用parallel_invoke
并行执行两个serialQuicksort
调用
如果对serialQuicksort
的两次调用执行的时间大致相同,并且没有资源限制,那么这种并行实现可以用顺序地一个接一个调用函数所需时间的一半来完成。
注意
作为开发人员,我们只有在函数可以安全地并行执行时,才负责并行调用函数。也就是说,TBB 将 而不是 自动识别依赖性,并应用同步、私有化或其他并行化策略来确保代码安全。当我们使用parallel_invoke
或本章中讨论的任何并行算法时,这就是我们的责任。
使用parallel_invoke
很简单,但是对parallel_invoke
的一次调用不太具有可伸缩性。一个可扩展的算法可以有效地利用可用的额外内核和硬件资源。
一种算法显示出强伸缩性如果随着额外内核的增加,解决一个固定大小的问题所需的时间减少。例如,当两个内核可用时,表现出良好的强扩展性的算法完成给定数据集的处理的速度可能比顺序算法快两倍,但是当 100 个内核可用时,完成相同数据集的处理的速度比顺序算法快 100 倍。
如果随着更多处理器的增加,每个处理器用相同的时间解决固定数据集大小的问题,算法显示弱伸缩。例如,表现出良好弱伸缩性的算法在使用两个处理器的固定时间段内能够处理两倍于其顺序版本的数据,而在使用 100 个处理器的相同固定时间段内能够处理 100 倍于其顺序版本的数据。
使用一个parallel_invoke
来并行执行两个排序将不会显示强或弱的伸缩,因为该算法最多可以使用两个处理器。如果我们有 100 个处理器可用,其中 98 个将会闲置,因为我们没有给它们任何事情做。我们应该开发可扩展的应用程序,而不是像我们的示例那样编写代码,这样我们就可以实现一次并行,而无需在每次包含更多内核的新架构可用时都重新实施。
幸运的是,TBB 可以有效地处理嵌套并行(在第九章中有详细描述),因此我们可以通过在递归分治算法中使用parallel_invoke
来创建可伸缩的并行(这种模式我们将在第八章中讨论)。TBB 还包括其他通用并行算法,这将在本章后面介绍,这些算法针对的是已经证明对实现可扩展并行性有效的模式,比如循环。
一个稍微复杂一点的例子:Quicksort 的并行实现
递归分治算法的一个众所周知的例子是快速排序,如图 2-4 所示。Quicksort 的工作原理是递归地将一个数组放在枢轴值周围,将小于或等于枢轴值的值放在数组的左分区,将大于枢轴值的值放在数组的右分区。当递归到达基数为 1 的数组时,整个数组已经被排序。
图 2-4。
快速排序的串行实现
我们可以开发一个 quicksort 的并行实现,如图 2-5 所示,用一个parallel_invoke
代替两个对serialQuicksort
的递归调用。除了使用parallel_invoke
,我们还引入了一个截止值。在最初的串行快速排序中,我们一直向下递归划分到单个元素的数组。
注意
生成和调度 TBB 任务并不是免费的——经验法则是,任务应该至少执行 1 微秒或 10,000 个处理器周期,以减少与任务创建和调度相关的开销。在第十六章中,我们提供了实验来更详细地证明这一经验法则。
为了在我们的并行实现中限制开销,我们只递归调用parallel_invoke
直到我们降到 100 个元素以下,然后直接调用serialQuicksort
来代替。
图 2-5。
使用parallel_invoke
并行实现快速排序
您可能会注意到 quicksort 的并行实现有一个很大的局限性 shuffle 是完全串行完成的。在顶层,这意味着在任何并行工作开始之前,我们有一个在单线程上完成的O(n)
操作。这可能会限制加速。我们让那些有兴趣的人来看看已知的并行分区实现如何解决这个限制(参见本章末尾的“更多信息”一节)。
循环:parallel_for
、parallel_reduce
和parallel_scan
对于许多应用程序来说,执行时间主要由循环时间决定。有几种 TBB 算法可以表达并行循环,让我们可以快速地为应用程序中的重要循环添加可扩展的并行性。图 2-2 中标记为“简单循环”的算法是那些迭代空间的开始和结束可以通过循环开始的时间容易地确定的算法。
例如,我们知道在下面的循环中将有正好 N 次迭代,所以我们将其归类为简单循环:
TBB 所有的简单循环算法都基于两个重要的概念,一个范围和一个主体。范围代表一组可递归分割的值。对于循环,范围通常是迭代空间中的索引或者迭代器在遍历容器时将采用的值。主体是我们应用于范围内每个值的函数;在 TBB 中,主体通常作为 C++ lambda 表达式提供,但也可以作为函数对象提供(参见 Lambda 表达式–vs-用户定义的类 )。
parallel_for
:对范围内的每个元素应用主体
让我们从一个小的串行for
循环开始,它在每次迭代中将函数应用于数组的一个元素:
我们可以通过使用parallel_for
来创建这个循环的并行版本:
对parallel_for
可用接口的完整描述可以在附录 b 中找到。在小示例循环中,范围是半开区间[0, N),
步长是 1,主体是f(a[i])
。我们可以这样表达,如图 2-6 所示。
图 2-6。
使用parallel_for
创建并行回路
当 TBB 执行一个parallel_for
的时候,这个范围被分成几个迭代块。与主体配对的每个块成为一个任务,该任务被调度到参与执行算法的一个线程上。TBB 库为我们处理任务的调度,所以我们需要做的就是使用parallel_for
函数来表示循环的迭代应该并行执行。在后面的章节中,我们将讨论如何调整 TBB 并行循环的行为。现在,让我们假设对于可用内核的范围大小和数量,TBB 生成了大量的任务。在大多数情况下,这是一个很好的假设。
理解这一点很重要,通过使用parallel_for
,我们断言以任何顺序并行执行循环迭代是安全的。TBB 库没有检查并行执行一个parallel_for
(或者实际上任何通用算法)的迭代是否会产生与串行执行算法相同的结果——当我们选择使用并行算法时,确保这一点是我们作为开发人员的工作。在第五章中,我们讨论了 TBB 的同步机制,它可以用来让一些不安全的代码变得安全。在第六章中,我们将讨论提供线程安全数据结构的并发容器,这些数据结构有时也能帮助我们使代码线程安全。但最终,我们需要确保当我们使用并行算法时,读写访问模式的任何潜在变化都不会改变结果的有效性。我们还需要确保在并行代码中只使用线程安全的库和函数。
例如,下面的循环作为parallel_for
执行是 而不是 安全的,因为每次迭代依赖于前一次迭代的结果。改变这个循环的执行顺序将改变数组a
的元素中存储的最终值:
想象一下如果数组a={1,0,0,0,...,0}
。顺序执行此循环后,它将保持{1,2,3,4,...,N}
。但是如果循环执行顺序错误,结果将会不同。在寻找可以安全并行执行的循环时,一个心理练习是问自己,如果循环迭代一次全部执行,或者随机执行,或者逆序执行,结果是否相同。在这种情况下,如果a={1,0,0,0,...,0}
和循环的迭代以相反的顺序执行,当循环完成时a
将保持{1,2,1,1,...,1}
。显然,执行顺序对这个循环很重要!
数据依赖分析的正式描述超出了本书的范围,但可以在许多编译器和并行编程书籍中找到,包括迈克尔·沃尔夫(Pearson)的高性能并行计算编译器和艾伦和肯尼迪(Morgan Kaufmann)的现代架构优化编译器。英特尔 Parallel Studio XE 中的 Intel Inspector 等工具也可用于查找和调试线程错误,包括使用 TBB 的应用中的线程错误。
稍微复杂一点的例子:并行矩阵乘法
图 2-7 显示了为MxM
矩阵计算C = AB
的矩阵乘法循环嵌套的非优化串行实现。我们在这里使用这个内核是为了进行演示——如果您需要在实际应用中使用矩阵乘法,并且不认为自己是优化专家——使用数学库中的高度优化的实现几乎肯定会更好,这些实现实现了基本线性代数子程序(BLAS ),如英特尔数学内核库(MKL)、BLIS 或 ATLAS。矩阵乘法在这里是一个很好的例子,因为它是一个小内核,执行一个我们都很熟悉的基本操作。有了这些免责声明,让我们继续看图 2-7 。
图 2-7。
矩阵乘法的非优化实现
我们可以通过使用如图 2-8 所示的parallel_for
快速实现图 2-7 中矩阵乘法的并行版本。在这个实现中,我们使外部的i
循环并行。外部i
循环的迭代执行封闭的j
和k
循环,因此,除非M
非常小,否则将有足够的工作超过 1 微秒的经验法则。如果可能的话,最好使外部循环并行,以保持较低的开销。
图 2-8。
矩阵乘法的简单实现
图 2-8 中的代码很快为我们提供了一个矩阵乘法的基本并行版本。虽然这是一个正确的并行实现,但由于它遍历数组的方式,它将会大大降低性能。在第十六章中,我们将讨论parallel_for
中可以用来调优性能的高级特性。
parallel_reduce
:跨Range
计算单个结果
在应用程序中发现的另一个非常常见的模式是归约,通常被称为“归约模式”或“映射归约”,因为它往往与映射模式一起使用(参见第八章中关于模式术语的更多信息)。
归约从值的集合中计算出单个值。示例应用包括计算总和、最小值或最大值。
让我们考虑一个寻找数组中最大值的循环:
从一组值中计算最大值是一种关联操作;也就是说,对值组执行此操作,然后按顺序组合这些部分结果是合法的。计算最大值也是可交换的,所以我们甚至不需要以任何特定的顺序组合部分结果。
对于执行关联运算的循环,TBB 提供了函数parallel_reduce
:
附录 b 中提供了对parallel_reduce
接口的完整描述。
许多常见的数学运算是相关的,例如加法、乘法、计算最大值和计算最小值。一些操作在理论上是关联的,但是在实际系统中由于数值表示的限制而不是关联的。我们应该意识到依赖结合性对于并行性的影响(参见 结合性和浮点类型 )。
结合性和浮点类型
在计算机算术中,精确地表示实数并不总是可行的。相反,浮点类型如float, double,
和long double
用作近似值。这些近似的结果是,适用于实数运算的数学属性不一定适用于浮点运算。例如,虽然加法在实数上是结合的和可交换的,但在浮点数上却不是这样。
例如,如果我们计算N
个真实值的总和,每个值都等于 1.0,我们会期望结果是N
。
但是在float
表示中有有限数量的有效数字,因此并不是所有的整数值都能被精确地表示。例如,如果我们用N == 10e6
(1000 万)运行这个循环,我们将得到10000000
的输出。但是如果我们用N == 20e
6 执行这个循环,我们会得到16777216
的输出。变量 r 根本不能表示16777217
,因为标准的float
表示法有 24 位尾数(有效数),而16777217
需要 25 位。当我们添加1.0
时,结果向下舍入到16777216
,并且1.0
的每个后续加法也向下舍入到16777216
。平心而论,在每一步,16777216
的结果都是对16777217
的很好的近似。正是这些舍入误差的累积,使得最终的结果如此糟糕。
如果我们将这个和分成两个循环,并组合部分结果,我们在两种情况下都会得到正确的答案:
为什么呢?因为r
可以表示更大的数字,只是不总是精确的。tmp1
和tmp2
中的值具有相似的数量级,因此相加影响表示中可用的有效数字,我们得到的结果是 2000 万的良好近似值。这个例子是结合性如何改变使用浮点数的计算结果的一个极端例子。
这个讨论的要点是,当我们使用一个parallel_reduce
时,它使用结合性来并行计算和组合部分结果。因此,在使用浮点数时,与串行实现相比,我们可能会得到不同的结果。事实上,根据参与线程的数量,parallel_reduce
的实现可能会选择创建不同数量的部分结果。因此,在并行实现中,即使是相同的输入,我们也可能得到不同的结果。
在我们惊慌失措并得出永远不应该使用parallel_reduce
的结论之前,我们应该记住,使用浮点数的实现通常会产生一个近似值。相同的输入得到不同的结果并不一定意味着至少有一个结果是错误的。这仅仅意味着对于两次不同的运行,舍入误差不同地累积。作为开发人员,我们有责任决定这些差异对应用程序是否重要。
如果我们想确保我们至少在相同输入数据的每次运行中得到相同的结果,我们可以选择使用第十六章中描述的parallel_deterministic_reduce
。这种确定性实现总是创建相同数量的部分结果,并针对相同的输入以相同的顺序组合它们,因此每次运行的近似值都是相同的。
与所有简单的循环算法一样,要使用 TBB parallel_reduce
,我们需要提供范围(range)
和主体(func
)。但是我们还需要提供一个标识值(identity
)和一个归约体(reduction
)。
为了给一个parallel_reduce
创建并行性,TBB 库将range
分成块,并创建将func
应用于每个块的任务。在第十六章中,我们将讨论如何使用分区器来控制创建的块的大小,但是现在,我们可以假设 TBB 创建了适当大小的块来最小化开销和平衡负载。每个执行func
的任务以一个用identity
初始化的值init
开始,然后计算并返回其块的部分结果。TBB 库通过调用reduction
函数组合这些部分结果,为整个循环创建一个单一的最终结果。
identity
参数是一个值,当使用正在并行化的操作将其他值与其组合时,该值保持不变。众所周知,关于加法(加法恒等式)的恒等式元素是“0
”(自x + 0 = x
),关于乘法(乘法恒等式)的恒等式元素是“1
”(自x * 1 = x
)。reduction
函数获取两个部分结果并将它们组合起来。
图 2-9 显示了如何应用func
和reduction
函数从 16 个元素的数组中计算最大值,如果范围被分成四个块。在这个例子中,func
对数组元素应用的关联运算是 max(),单位元素是-
∞,因为max(x,-
∞ )=x
。在 C++ 中,我们可以用std::max
作为运算,用std::numeric_limits<int>::min()
作为-
∞的程序化表示。
图 2-9。
如何调用func
和reduction
函数来计算最大值
我们可以用图 2-10 所示的parallel_reduce
来表达我们简单的最大值循环。
图 2-10。
使用parallel_reduce
计算最大值
您可能会注意到,在图 2-10 中,我们为范围使用了一个blocked_range
对象,而不是像使用parallel_for
那样只提供范围的开始和结束。parallel_for
算法提供了一个简化的语法,这是parallel_reduce
所没有的。对于parallel_reduce
,我们必须直接传递一个 Range 对象,但幸运的是我们可以使用库提供的预定义范围之一,其中包括blocked_range
、blocked_range2d
和blocked_range3d
等等。这些其他范围对象将在第十六章中详细描述,它们的完整接口在附录 b 中提供。
图 2-10 中使用的blocked_range
代表一个 1D 迭代空间。为了构造一个,我们提供开始和结束值。在主体中,我们使用它的begin()
和end()
函数来获取主体执行所分配的值块的起始值和结束值,然后遍历该子范围。在图 2-8 中,范围中的每个单独的值都被发送到parallel_for
主体,因此不需要i
循环来迭代范围。在图 2-10 中,主体接收到一个代表迭代块的blocked_range
对象,因此我们仍然有一个i
循环来迭代分配给它的整个块。
稍微复杂一点的例子:用数值积分计算π
图 2-11 显示了通过数值积分计算π的方法。使用勾股定理计算每个矩形的高度。单位圆一个象限的面积在循环中计算,乘以 4 得到圆的总面积,等于π。
图 2-11。
用矩形积分法进行连续π计算
图 2-11 中的代码计算所有矩形的面积之和,这是一个归约操作。要使用 TBB parallel_reduce
,我们需要识别range
、body
、identity
值和reduction
函数。本例中,range
为0, num_intervals)
,body
与图 [2-11 中的i
回路相似。因为我们正在执行求和操作,所以identity
值为0.0
。而需要组合部分结果的reduction
主体将返回两个值的和。使用 TBB parallel_reduce
的并行实现如图 2-12 所示。
图 2-12。
使用tbb:
实现圆周率:parallel_reduce
和parallel_for
一样,有一些高级特性和选项可以和parallel_reduce
一起使用来调整性能和管理舍入误差(参见结合性和浮点类型)。这些高级选项将在第十六章中介绍。
parallel_scan
:具有中间值的缩减
应用程序中一种不太常见但仍然重要的模式是扫描(有时称为前缀)。扫描类似于归约,但它不仅从值的集合中计算单个值,还计算范围内每个元素的中间结果(前缀)。一个例子是值x
0
, x
1
, ... x
N
的运行总和。结果包括运行总和中的各个值,y
0
, y
1
... y
N
,以及最终总和y
N
。
-
y
0
= x
0
-
y
1
= x
0
+ x
T5 -
. . .
-
y
N
= x
0
+ x
1
+ ... + x
N
根据向量v
计算累计和的串行循环如下:
从表面上看,扫描看起来像一个串行算法。每个前缀取决于所有先前迭代中计算的结果。虽然看起来令人惊讶,但是这种看似串行的算法也有高效的并行实现。TBB parallel_scan
算法实现了高效的并行扫描。它的接口要求我们提供一个range
、一个identity value
、一个scan body
和一个combine body
:
range
、identity value
、combine body
类似于parallel_reduce
的range
、identity value
、reduction body
。和其他简单的循环算法一样,range
被 TBB 库分成块,TBB 任务被创建来将主体(scan
)应用到这些块。附录 b 中提供了parallel_scan
接口的完整描述。
parallel_scan
的不同之处在于scan
主体可以在同一个迭代块上执行多次——首先是在预扫描模式下,然后是在最终扫描模式下。
在最终扫描模式中,主体被传递一个精确的前缀结果,该结果是紧接在其子范围之前的迭代的结果。使用这个value
,主体计算并存储其子范围中每个迭代的前缀,并返回其子范围中最后一个元素的准确前缀。
然而,当在预扫描模式下执行扫描主体时,它接收一个起始前缀值,该值不是其给定范围之前的元素的最终值。就像parallel_reduce
一样,parallel_scan
依赖于结合性。在预扫描模式下,起始前缀值可能代表它前面的子范围,但不是它前面的完整范围。使用这个值,它返回其子范围中最后一个元素的前缀(还不是最终的)。返回值表示起始前缀及其子范围的部分结果。通过使用这些预扫描和最终扫描模式,可以在扫描算法中利用有用的并行性。
这是如何工作的?
让我们再来看一下运行总和的例子,并考虑用三个模块A
、B
和C
来计算它。在一个连续的实现中,我们计算A
,然后B
,然后C
的所有前缀(按顺序完成三个步骤)。我们可以用并行扫描做得更好,如图 2-13 所示。
图 2-13。
并行执行扫描以计算总和
首先,我们在最终扫描模式下计算A
的扫描,因为它是第一组值,所以如果它被传递一个初始值identity
,它的前缀值将是准确的。在我们启动A
的同时,我们以预扫描模式启动B
。一旦这两次扫描完成,我们现在可以计算出B
和C
的准确起始前缀。向B
提供来自A
( 92
)的最终结果,向C
提供A
的最终扫描结果与B
( 92+136 = 228
)的预扫描结果的组合。
组合操作需要恒定的时间,因此比扫描操作便宜得多。不像顺序实现采用三个大步骤一个接一个地应用,并行实现并行执行A
的最终扫描和B
的预扫描,然后执行恒定时间合并步骤,然后最终并行计算B
和C
的最终扫描。如果我们至少有两个内核,并且N
足够大,那么使用三个块的并行前缀和可以在顺序实现的大约三分之二的时间内计算出来。并且parallel_prefix
当然可以使用三个以上的块来执行,以利用更多的内核。
图 2-14 显示了使用 TBB parallel_scan
的简单部分和示例的实现。range
是区间1, N)
,identity value
是0
,combine
函数返回其两个参数之和。scan body
返回其子范围内所有值的部分和,加到它接收的初始sum
上。然而,只有当它的is_final_scan
参数为true
时,它才会将前缀结果分配给running_sum
数组。
![../img/466505_1_En_2_Fig14_HTML.png
图 2-14。
使用parallel_scan
实现运行总和
稍微复杂一点的例子:视线
图 2-15 显示了一个视线问题的串行实现,该问题类似于 Guy E. Blelloch(麻省理工学院出版社)的数据并行计算矢量模型中描述的问题。给定观察点的高度和距观察点固定间隔的点的高度,视线代码确定从观察点可见的点。如图 2-15 所示,如果一个点与视点altitude[0]
之间的任意一点具有较大的ѳ.角,则该点不可见串行实现执行扫描以计算给定点和观察点之间所有点的最大ѳ值。如果给定点的ѳ值大于这个最大角度,那么它就是一个可见点;否则,它不可见。
图 2-15。
视线示例
图 2-16 显示了使用 TBB parallel_scan
的视线示例的并行实现。当算法完成时,is_visible
数组将包含每个点的可见性(true
或false
)。需要注意的是,图 2-16 中的代码需要计算每个点的最大角度,以确定该点的可见性,但最终输出的是每个点的可见性,而不是每个点的最大角度。因为max_angle
是需要的,但不是最终结果,它在pre-scan
和final-scan
模式下都被计算,但is_visible
值仅在final-scan
执行期间为每个点存储。
图 2-16。
使用parallel_scan
实现视线
煮熟为止:parallel_do 和 parallel_pipeline
对于某些应用程序,简单的循环可以让我们全面了解有用的并行性。但是对于其他的,我们需要在循环中表达并行性,在循环开始之前不能完全计算范围。例如,考虑一个 while 循环:
这个循环一直读取图像,直到没有更多的图像可以读取。每幅图像被读取后,由函数f
进行处理。我们不能使用parallel_for
,因为我们不知道将会有多少图像,因此不能提供一个范围。
一个更微妙的情况是,我们有一个不提供随机访问迭代器的容器:
注意
在 C++ 中,迭代器是一个对象,它指向一个元素范围中的一个元素,并定义提供遍历该范围中的元素的能力的操作符。迭代器有不同的类别,包括正向、双向和随机访问迭代器。随机访问迭代器可以在常量时间内指向范围内的任何元素。
因为一个std::list
不支持对其元素的随机访问,我们可以获得范围my_images.begin()
和my_images.end(),
的定界符,但是如果不依次遍历列表,我们就不能到达这两个点之间的元素。因此,TBB 库不能快速地(在恒定的时间内)创建迭代块来作为任务分发,因为它不能快速地指向这些块的开始和结束点。
为了处理这样的复杂循环,TBB 库提供了两个通用算法:parallel_do
和parallel_pipeline
。
parallel_do
:应用一个身体,直到没有更多的项目了
TBB parallel_do
将主体应用于工作项目,直到不再有项目需要处理。一些工作项目可以在循环开始时预先提供,其他的可以在主体执行处理其他项目时添加。
parallel_do
函数有两个接口,一个接受第一个和最后一个迭代器,另一个接受容器。附录 b 中提供了对parallel_do
接口的完整描述。在本节中,我们将查看接收容器的版本:
作为一个简单的例子,让我们从一个std::pair<int, bool>
元素的std::list
开始,每个元素包含一个随机整数value
和false
。对于每个元素,我们将计算int value
是否是质数;如果是,我们将true
存储到bool value
。我们将假设我们被给定了填充容器并确定一个数是否是质数的函数。串行实现如下:
我们可以使用 TBB parallel_do
创建这个循环的并行实现,如图 2-17 所示。
图 2-17。
使用parallel_do
实现质数循环
TBB parallel_do
算法将安全地顺序遍历容器,同时创建任务将主体应用于每个元素。因为必须顺序遍历容器,所以parallel_do
不像parallel_for
那样可伸缩,但是只要主体相对较大(> 100,000 个时钟周期),遍历开销与主体在元素上的并行执行相比可以忽略不计。
除了处理不提供随机访问的容器之外,parallel_do
还允许我们从主体执行中添加额外的工作项。如果主体正在并行执行,并且它们添加了新的项目,那么这些项目也可以并行生成,从而避免了parallel_do
的顺序任务生成限制。
图 2-18 提供了一个计算值是否是质数的串行实现,但是这些值存储在一个树中而不是一个列表中。
图 2-18。
检查元素树中的质数
我们可以使用如图 2-19 所示的parallel_do,
来创建这个树版本的并行实现。为了突出显示提供工作项的不同方式,在这个实现中,我们使用了一个保存单个值树的容器。parallel_do
只从一个工作项开始,但是在每个主体执行中添加了两个项,一个处理左子树,另一个处理右子树。我们使用parallel_do_feeder.add
方法向迭代空间添加新的工作项。类parallel_do_feeder
由 TBB 库定义,并作为第二个参数传递给主体。
随着主体遍历树的各个级别,可用工作项的数量呈指数增长。在图 2-19 中,我们甚至在检查当前元素是否为质数之前就通过feeder
添加了新的项目,以便其他任务尽可能快地产生。
图 2-19。
使用 TBB 检查元素树中的质数parallel_do
我们应该注意到,我们考虑的parallel_do
的两种用法有可能因为不同的原因而伸缩。第一个实现没有图 2-17 中的进给器,如果每个主体执行都有足够的工作来减少顺序遍历列表的开销,那么它可以表现出良好的性能。在第二个实现中,使用图 2-19 中的 feeder,我们只从一个工作项目开始,但是随着主体执行和添加新项目,可用工作项目的数量会快速增长。
一个稍微复杂一点的例子:正向替换
正向代换是求解一组方程Ax = b
的方法,其中A
是一个nxn
下三角矩阵。作为矩阵来看,这组方程看起来像
并且可以一次解决一行:
该算法直接实现的串行代码如图 2-20 所示。在串行代码中,b 被破坏性地更新以存储每行的总和。
图 2-20。
向前替换的直接实现的串行代码。编写该实现是为了使算法清晰明了,而不是为了获得最佳性能。
图 2-21(a) 显示了图 2-20 中i,j
循环嵌套体迭代之间的依赖关系。内部j
循环的每次迭代(如图中的行所示)执行到b[i]
的归约,并且还依赖于在i
循环的早期迭代中编写的x
的所有元素。我们可以使用parallel_reduce
来并行化内部的j
循环,但是在i
循环的早期迭代中可能没有足够的工作来实现这一点。图 2-21(a) 中的虚线显示了在这个循环嵌套中还有另外一种寻找并行性的方法,那就是对角穿过迭代空间。我们可以通过使用parallel_do
来利用这种并行性,仅在满足依赖关系时添加迭代,类似于我们在图 2-19 中发现新的树元素时添加它们的方式。
图 2-21。
8 × 8 小矩阵正向代换中的依赖性。在(a)中,显示了迭代之间的依赖性。在(b)中,迭代被分组为块以减少调度开销。在(a)和(b)中,每个块都必须等待它上面的邻居和它左边的邻居完成,然后才能安全执行。
如果我们分别表示每个迭代的并行性,我们将创建太小而不能克服调度开销的任务,因为每个任务将只是一些浮点操作。相反,我们可以修改循环嵌套来创建迭代块,如图 2-21(b) 所示。依赖模式保持不变,但是我们将能够把这些更大的迭代块作为任务来调度。串行代码的封锁版本如图 2-22 所示。
图 2-22。
向前替换的串行实现的阻塞版本
使用parallel_do
的并行实现如图 2-23 所示。这里,我们使用parallel_do
的接口,它允许我们指定开始和结束迭代器,而不是整个容器。你可以在附录 b 中看到这个接口的细节。
与图 2-19 中的质数树示例不同,我们不想简单地将每个相邻的块发送到馈送器。相反,我们初始化一个计数器数组,ref_count,
来保存在每个块被允许开始执行之前必须完成的块数。原子变量将在第五章中详细讨论。对于我们这里的目的,我们可以把这些看作是我们可以安全地并行修改的变量;特别是,递减是以线程安全的方式完成的。我们初始化计数器,使左上角的元素没有依赖关系,第一列和对角线上的块有一个依赖关系,所有其他的有两个依赖关系。这些计数与图 2-21 所示的每个模块的前任数量相匹配。
图 2-23。
使用parallel_do
实现正向替换
在图 2-23 中对parallel_do
的调用中,我们最初只提供了左上角的块&top_left, &top_left+1)
。但是在每个主体执行中,底部的if
-语句会递减依赖于刚刚处理的块的块的原子计数器。如果计数器达到零,则该块满足其所有依赖性,并被提供给馈送器。
和前面的质数例子一样,这个例子展示了使用parallel_do:
的应用程序的特点。并行性受到顺序访问容器的需求或动态查找工作项目并将其提供给算法的需求的限制。
parallel_pipeline
:通过一系列过滤器流式传输项目
TBB 用于处理复杂循环的第二个通用并行算法是parallel_pipeline
。管道是一系列线性的过滤器,当项通过它们时,它们会对其进行转换。管道通常用于处理流入应用程序的数据,如视频或音频帧或金融数据。在第 [3 章中,我们将讨论流图接口,它让我们可以构建更复杂的图形,包括进出滤波器的扇入和扇出。
图 2-24 显示了一个小的示例循环,它读入字符数组,通过将所有小写字符转换为大写字符以及将所有大写字符转换为小写字符来转换字符,然后将结果按顺序写入输出文件。
图 2-24。
一个系列案例变化的例子
操作必须在每个缓冲区上按顺序进行,但是我们可以重叠应用于不同缓冲区的不同过滤器的执行。图 2-25(a) 将此示例显示为一个流水线,其中“写缓冲区”在buffer
i
上运行,而并行的“处理”过滤器在buffer
i+1
上运行,“获取缓冲区”过滤器在buffer
i+2
中读取。
图 2-25。
使用管道的案例更改示例
如图 2-25(b) 所示,在稳定状态下,每个过滤器都很忙,它们的执行是重叠的。然而,如图 2-25(c) 所示,不平衡滤波器会降低加速比。串行滤波器流水线的性能受到最慢串行级的限制。
TBB 库支持串行和并行过滤器。并行过滤器可以并行应用于不同的项目,以增加过滤器的吞吐量。图 2-26(a) 显示了“案例变化”的例子,中间/过程过滤器在两个项目上并行执行。图 2-26(b) 说明了如果中间的过滤器在任何给定的项目上花费的时间是其他过滤器的两倍,那么给这个过滤器分配两个线程将允许它匹配其他过滤器的吞吐量。
图 2-26。
使用具有并行过滤器的管道的情况变化示例。通过使用并行过滤器的两个副本,流水线最大化了吞吐量。
附录 b 中提供了对parallel_pipeline
接口的完整描述。我们在本节中使用的parallel_pipeline
接口如下所示:
第一个参数max_number_of_live_tokens
是在任何给定时间允许流经管道的最大项目数。该值对于限制资源消耗是必要的。例如,考虑简单的三个过滤器管道。如果中间的滤波器是一个串行滤波器,并且它比获得新缓冲器的滤波器花费的时间长 1000 倍呢?第一个过滤器可能会分配 1000 个缓冲区,仅用于在第二个过滤器之前对它们进行排队,从而浪费大量内存。
parallel_pipeline
的第二个参数是filter_chain
,这是一系列通过串联使用make_filter
函数创建的过滤器而创建的过滤器:
模板参数 T 和 U 指定过滤器的输入和输出类型。模式参数可以是serial_in_order
、serial_out_of_order
或 parallel。f 参数是过滤器的主体。图 2-27 显示了使用 TBB parallel_pipeline
实现案例变更示例。附录 b 中提供了对parallel_pipeline
接口的更完整描述。
我们可以注意到,第一个过滤器,因为它的输入类型是void
,接收类型为tbb::flow_control.
的特殊参数。当管道中的第一个过滤器不再生成新项目时,我们使用该参数来发出信号。比如图 2-27 中的第一个过滤器,当getCaseString()
返回的指针为null
时,我们调用stop()
。
图 2-27。
使用具有并行中间过滤器的管道的情况变化示例
在该实现中,使用serial_in_order
模式创建第一个和最后一个过滤器。这指定了两个过滤器一次只能对一个项目运行,并且最后一个过滤器应该按照第一个过滤器生成项目的顺序执行项目。一个serial_out_of_order
过滤器被允许以任何顺序执行项目。中间的过滤器通过parallel
作为它的模式,允许它并行执行不同的项目。parallel_pipeline
支持的模式在附录 b 中有更详细的描述。
一个稍微复杂一点的例子:创建 3D 立体图像
图 2-28 显示了一个更复杂的管道示例。while 循环读入帧数,然后为每一帧读取左右图像,给左图像添加红色,给右图像添加蓝色。然后,它将生成的两幅图像合并成一幅红-青 3D 立体图像。
图 2-28。
红青色 3D 立体样本应用程序
与简单的 case change 示例类似,我们也有一系列通过一组过滤器的输入。我们识别重要的函数,并将它们转换成管道过滤器:getNextFrameNumber
、getLeftImage
、getRightImage
、increasePNGChannel
(到左图)、increasePNGChannel
(到右图)、mergePNGImages
和right.write()
。图 2-29 显示了绘制成管道的示例。increasePNGChannel
滤镜应用两次,第一次在左图像上,然后在右图像上。
图 2-29。
作为流水线的 3D 立体采样应用
使用 TBB parallel_pipeline
的并行实现如图 2-30 所示。
图 2-30。
使用parallel_pipeline
实现的立体 3D 示例
TBB parallel_pipeline
函数对管道滤波器进行线性化。当来自第一级的输入流过管道时,过滤器被一个接一个地应用。这实际上是对这个例子的限制。在mergeImageBuffers
滤波器之前,左右图像的处理是独立的,但是由于parallel_pipeline
的接口,滤波器必须线性化。即便如此,只有读入图像的过滤器是串行过滤器,因此,如果执行时间由后面的并行阶段支配,则该实现仍然是可伸缩的。
在第三章中,我们介绍了 TBB 流图,它将允许我们更直接地表达受益于滤波器非线性执行的应用。
摘要
本章提供了 TBB 库提供的通用并行算法的基本概述,包括捕获功能并行、简单和复杂循环以及流水线并行的模式。这些预先打包的算法(模式)提供了经过充分测试和调整的实现,可以逐步应用到应用程序中以提高性能。
本章显示的代码提供了一些小例子,展示了如何使用这些算法。在本书的第二部分(从第九章开始),我们将讨论如何以可组合的方式组合这些算法,并使用可用于优化局部性、最小化开销和添加优先级的库特性来调优应用程序,从而充分利用 TBB。本书的第二部分还讨论了在使用 TBB 通用并行算法时如何处理异常处理和取消。
我们将在下一章继续,看看 TBB 的另一个高级特征,流程图。
更多信息
这里有一些我们推荐的与本章相关的额外阅读材料。
-
我们讨论了并行编程的设计模式,以及它们与 TBB 通用并行算法的关系。设计模式的集合可以在
Timothy Mattson,Beverly Sanders 和 Berna Massingill,并行编程的模式(第一版。),2004 年,艾迪森-卫斯理专业。
-
在讨论 quicksort 的并行实现时,我们注意到分区仍然是一个串行瓶颈。讨论并行分区实现的文章包括
页(page 的缩写)Heidelberger,A. Norton 和 J. T. Robinson,“使用取加的并行快速排序”,1990 年 1 月,IEEE 计算机汇刊第 39 卷第 1 期第 133-138 页。
页(page 的缩写)齐加斯和张艺谋。快速排序的简单快速并行实现及其在 SUN enterprise 10000 上的性能评估。在第 11 届欧洲并行、分布式和基于网络的处理研讨会上(PDP 2003),第 372–381 页,2003 年。
-
您可以在许多编译器或并行编程书籍中了解更多关于数据依赖分析的知识,包括
Michael Joseph Wolfe,面向并行计算的高性能编译器, 1995,Addison-Wesley Longman 出版公司,波士顿,MA,美国。
肯尼迪和约翰·R·艾伦,现代体系结构的优化编译器, 2001,摩根考夫曼出版公司,旧金山,加利福尼亚州,美国。
-
当我们讨论矩阵乘法时,我们注意到除非我们是优化专家,否则我们通常更喜欢使用线性代数内核的预打包实现。
这种包包括
www.netlib.org/blas/
中的基本线性代数子程序(BLAS)英特尔数学内核库(英特尔 MKL)位于
https://software.intel.com/mkl
自动调谐线性代数软件(图集)发现
http://math-atlas.sourceforge.net/
FLAME 项目研究和开发密集线性代数库。他们的 BLIS 软件框架可以用来创建高性能的 BLAS 库。火焰项目可以在
www.cs.utexas.edu/~flame
找到。 -
本章中的视线示例是根据中提供的说明使用并行扫描实现的
数据并行计算的向量模型,Guy E. Blelloch(麻省理工学院出版社)。
图 2-28a 、 2-29 和 3-7 中使用的照片由 Elena Adams 拍摄,经 Halide 项目教程 http://halide-lang.org
许可使用。
开放存取本章根据知识共享署名-非商业-非专用 4.0 国际许可协议(http://Creative Commons . org/licenses/by-NC-nd/4.0/)的条款进行许可,该协议允许以任何媒体或格式进行任何非商业使用、共享、分发和复制,只要您适当注明原作者和来源,提供知识共享许可协议的链接,并指出您是否修改了许可材料。根据本许可证,您无权共享从本章或其部分内容派生的改编材料。
本章中的图像或其他第三方材料包含在本章的知识共享许可中,除非在材料的信用额度中另有说明。如果材料不包括在本章的知识共享许可中,并且您的预期使用不被法律法规允许或超出了允许的使用范围,您将需要直接从版权所有者处获得许可。**
三、流程图
在第二章中,我们介绍了一组匹配我们在应用中经常遇到的模式的算法。那些太棒了!我们应该尽可能地使用它们。不幸的是,并不是所有的应用程序都适合这些盒子;它们可能会很乱。当事情开始变得混乱时,我们会变成控制狂,试图对每件事都进行微观管理,或者只是决定“随波逐流”,对事情的发展做出反应。TBB 让我们选择任何一条道路。
在第十章中,我们讨论了如何直接使用任务来创建我们自己的算法。任务既有高级接口,也有低级接口,所以如果我们直接使用任务,如果我们真的想成为控制狂,我们可以选择。
然而,在本章中,我们将关注线程构建模块流程图界面。第二章中的大多数算法都是面向那些我们预先有大量数据,并且需要创建任务来分割和并行处理这些数据的应用程序的。流程图适用于在数据可用时做出反应的应用程序,或者具有比简单结构所能表达的更复杂的依赖性的应用程序。流图接口已经成功地用于广泛的领域,包括图像处理、人工智能、金融服务、医疗保健和游戏。
流图接口让我们表达包含并行性的程序,这些并行性可以用图来表达。在许多情况下,这些应用程序通过一组过滤器或计算来传输数据流。我们称这些数据流图为。图形还可以表达操作之间的前后关系,允许我们表达不能用并行循环或管道容易表达的依赖结构。一些线性代数计算,例如乔莱斯基分解,有高效的并行实现,通过跟踪较小操作的依赖性来避免重量级的同步点。我们称表达这些前后关系的图为依赖图。
在第二章中,我们介绍了两种通用的并行算法,像流程图一样,不需要提前知道所有的数据,parallel_do
和parallel_pipeline
。这些算法在应用时非常有效;然而,这两种算法都有流图所没有的限制。一个parallel_do
只有一个单一的体函数,当它可用时应用于每个输入项。A parallel_pipeline
在输入项流经管道时对其应用一系列线性过滤器。在第二章的最后,我们看了一个 3D 立体示例,它比一系列线性滤镜具有更多的并行性。流程图 API 让我们表达比parallel_do
或parallel_pipeline
更复杂的结构。
在这一章中,我们首先讨论基于图的并行性为什么重要,然后讨论 TBB 流图 API 的基础知识。之后,我们探索两种主要类型的流图的例子:数据流图和依赖图。
为什么要用图来表示并行?
用计算图表示的应用程序公开了可以在运行时有效地用来并行调度其计算的信息。我们可以看看图 3-1(a) 中的代码作为例子。
图 3-1。
可以表示为数据流图的应用程序
在图 3-1(a) 中 while 循环的每次迭代中,一幅图像被读取,然后通过一系列过滤器:f1、f2、f3 和 f4。我们可以绘制这些过滤器之间的数据流,如图 3-1(b) 所示。在此图中,用于传递从每个函数返回的数据的变量被替换为从生成值的节点到消费值的节点的边。
现在,让我们假设图 3-1(b) 中的图表捕获了这些功能之间共享的所有数据。如果是这样,我们(以及像 TBB 这样的库)可以推断出很多关于并行执行什么是合法的,如图 3-2 所示。
图 3-2 显示了从我们的小例子的数据流图表示中可以推断出的并行类型。在图中,我们通过图表传输四个图像。因为节点 f2 和 f3 之间没有边,所以它们可以并行执行。在相同的数据上并行执行两个不同的功能是功能并行(任务并行)的一个例子。如果我们假设这些函数是无副作用的,也就是说,它们不更新全局状态,只从它们的传入消息中读取和写入它们的传出消息,那么我们也可以在图中重叠不同消息的处理,利用流水线并行。最后,如果函数是线程安全的,也就是说,我们可以在不同的输入上并行执行每个函数,那么我们也可以选择在同一节点中重叠两个不同映像的执行,以利用数据并行性。
图 3-2。
从图表中可以推断出的并行度的种类
当我们使用 TBB 流图接口将我们的应用表示为图形时,我们向库提供了利用这些不同种类的并行性所需的信息,因此它可以将我们的计算映射到平台硬件以提高性能。
TBB 流程图界面的基础
TBB 流图的类和函数在flow_graph.h
中定义,并包含在tbb::flow
名称空间中。包罗万象的tbb.h
也包括flow_graph.h
,所以如果我们使用那个头,我们不需要包括任何其他东西。
为了使用流图,我们首先创建一个图对象。然后我们创建节点来对流经图的消息执行操作,比如应用用户计算、连接、分离、缓冲或重新排序消息。我们用边来表示这些节点之间的消息通道或依赖关系。最后,在我们从图对象、节点对象和边组装了一个图之后,我们将消息输入到图中。消息可以是基本类型、对象或指向对象的指针。如果我们想等待处理完成,我们可以使用 graph 对象作为句柄。
图 3-3 显示了一个小例子,它执行了使用 TBB 流图所需的五个步骤。在本节中,我们将更详细地讨论这些步骤。
图 3-3。
具有两个节点的示例流程图
步骤 1:创建图形对象
创建流图的第一步是构造一个图形对象。在流程图界面中,图对象用于调用整个图的操作,例如等待与图的执行相关的所有任务完成,重置图中所有节点的状态,以及取消图中所有节点的执行。当构建一个图时,每个节点恰好属于一个图,并且在同一个图中的节点之间形成边。一旦我们构建了图,那么我们需要构建实现图的计算的节点。
步骤 2:制作节点
TBB 流图接口定义了一组丰富的节点类型(图 3-4 ),大致可以分为三组:功能节点类型、控制流节点类型(包括连接节点类型)和缓冲节点类型。在附录 b 的“流图:节点”一节中可以找到对 graph 类提供的接口和所有节点类型提供的接口的详细回顾。我们并不期望您现在就详细阅读这些表,而是希望您知道在本章和后续章节中使用节点类型时可以引用它们。
图 3-4。
流程图节点类型(参见章节 3 、 17 、 18 、19;附录 B)中的接口细节
像所有的函数节点一样,function_node
将 lambda 表达式作为其参数之一。我们在功能节点中使用这些主体参数来提供我们想要应用于传入消息的代码。在图 3-3 中,我们定义了第一个节点来接收一个int
值,打印该值,然后将其转换为一个std::string,
,返回转换后的值。该节点复制如下:
节点通常通过边相互连接,但是我们也可以显式地向节点发送消息。例如,我们可以通过调用try_put
向my_first_node
发送消息:
my_first_node.try_put(10);
这导致 TBB 库产生一个任务来执行int
消息 10 上的my_first_node
主体,产生如下输出
first node received: 10
与我们提供主体参数的功能节点不同,控制流节点类型执行预定义的操作,这些操作在消息流经图时连接、拆分或定向消息。例如,我们可以创建一个join_node
,它将来自多个输入端口的输入连接在一起,通过提供元组类型、连接策略和对图形对象的引用来创建一个类型为std::tuple<int, std::string, double>
的输出:
这个join_node
、j
,有三个输入端口和一个输出端口。输入端口 0 将接受类型为int
的消息。输入端口 1 将接受类型为std::string
的消息。输入端口 2 将接受double
类型的消息。将有一个单一的输出端口来广播std::tuple<int, std::string, double>.
类型的消息
一个join_node
可以有四个连接策略之一:queueing
、reserving, key_matching
和tag_matching
。对于queueing
、key_matching
和tag_matching
策略,join_node
在消息到达其每个输入端口时对其进行缓冲。queueing
策略将传入的消息存储在每个端口的队列中,使用先进先出的方法将消息加入到一个元组中。key_matching
和tag_matching
策略将传入的消息存储在每个端口的映射中,并根据匹配的键或标签连接消息。
预留join_node
根本不缓冲传入的消息。相反,它跟踪前面的缓冲区的状态——当它认为每个输入端口都有可用的消息时,它会尝试为每个输入端口保留一个项目。当保留被保持时,保留防止任何其他节点消费该项目。只有当join_node
能够成功地为每个输入端口获取一个元素的预留时,它才消费这些消息;否则,它释放所有的预留并将消息留在前面的缓冲区中。如果一个预留join_node
未能预留所有的输入,它稍后再试。我们将在第十七章中看到这种预留策略的使用案例。
缓冲节点输入缓冲消息。由于功能节点function_node
和multifunction_node,
在其输入端包含缓冲器,而source_node
在其输出端包含缓冲器,因此缓冲节点在有限的情况下使用——通常与预留节点join_node
一起使用(参见第十七章)。
步骤 3:添加边缘
在我们构建了一个图形对象和节点之后,我们使用make_edge
调用来设置消息通道或依赖关系:
make_edge(predecessor_node, successor_node);
如果一个节点有多个输入端口或输出端口,我们使用input_port
和output_port
功能模板来选择端口:
make_edge(output_port<0>(predecessor_node),
input_port<1>(successor_node));
在图 3-3 中,我们在简单的双节点图中的my_first_node
和my_second_node
之间做了一条边。图 3-5 显示了一个稍微复杂一点的流程图,有四个节点。
图 3-5。
具有四个节点的示例流程图
图 3-5 中的前两个节点生成结果,这些结果通过排队join_node
、my_join_node
连接在一起成为一个元组。当边缘被制作到join_node
的输入端口时,我们需要指定端口号:
make_edge(my_node, tbb::flow::input_port<0>(my_join_node));
make_edge(my_other_node, tbb::flow::input_port<1>(my_join_node));
join_node
的输出,即std::tuple<std::string, double>
,被发送到my_final_node
。当只有一个端口时,我们不需要指定端口号:
make_edge(my_join_node, my_final_node);
第四步:开始绘制图表
创建和使用 TBB 流图的第四步是开始执行图。消息进入图有两种主要方式:( 1)通过一个显式的try_put
到一个节点,或者(2)作为一个source_node
的输出。在图 3-3 和图 3-5 中,我们在节点上调用try_put
来开始消息流入图中。
默认情况下,在活动状态下构建一个source_node
。每当形成传出边缘时,它立即开始跨边缘发送消息。不幸的是,我们认为这很容易出错,所以我们总是在非活动状态下构造源节点,也就是说,将 false 作为is_active
参数传递。为了在我们的图被完全构建后让消息流动,我们在所有不活动的节点上调用activate()
函数
图 3-6 展示了如何使用source_node
代替串行回路向图形提供信息。在图 3-6(a) 中,一个循环在一个节点my_node
上重复调用try_put
,向其发送消息。在图 3-6(b) 中,a source_node
用于相同的目的。
source_node
的返回值就像串行循环中的布尔条件一样使用——如果为真,则执行循环体的另一次执行;否则,循环停止。由于source_node
的返回值用于表示布尔条件,所以它通过更新提供给其主体的参数来返回其输出值。在图 3-6(b) 中,source_node
取代了图 3-6(a) 中的计数回路。
图 3-6。
在(a)中,循环将int
值0, 1
和2
发送到节点my_node
。在(b)中,a source_node
将int
值0, 1
和2
发送到节点my_node
。
使用source_node
而不是循环的主要优点是它响应图中的其他节点。在第十七章中,我们将讨论如何使用source_node
和预留join_node
或limiter_node
来控制允许多少消息进入一个图。如果我们使用一个简单的循环,我们可以用输入来淹没我们的图,如果节点跟不上,迫使节点缓冲许多消息。
步骤 5:等待图形完成执行
一旦我们使用try_put
或source_node
将消息发送到图表中,我们就通过调用图表对象上的wait_for_all()
来等待图表的执行完成。我们可以在图 3-3 、图 3-5 和图 3-6 中看到这些呼叫。
如果我们构建并执行图 3-3 中的图形,我们会看到如下输出
first node received: 10
second node received: 10
如果我们构建并执行图 3-5 中的图表,我们会看到如下输出
other received: received: 21
final: 1 and 2
图 3-5 的输出看起来有点混乱,确实如此。前两个功能节点并行执行,都流向std::cout
。在我们的输出中,我们看到两个输出混杂在一起,因为我们打破了我们在本章早些时候讨论基于图的并行性时所做的假设——我们的节点不是没有副作用的!这两个节点并行执行,并且都影响全局std::cout
对象.
的状态。在本例中,这是可以的,因为输出只是为了通过图形显示消息的进度。但这是需要记住的重要一点。
图 3-5 中的最后一个function_node
只有当来自前面函数节点的两个值被join_node
连接在一起并传递给它时才会执行。因此,这个最终节点自己执行,因此它将预期的最终输出流式传输到std::cout:
“final:1 和 2”。
数据流图的一个更复杂的例子
在第二章中,我们介绍了一个将红-青 3D 立体效果应用于左右图像对的例子。在第二章中,我们用一个 TBB parallel_pipeline
对这个例子进行了并行化,但这样做意味着我们通过线性化流水线阶段在桌面上留下了一些并行性。输出示例如图 3-7 所示。
图 3-7。
左图像和右图像用于生成红-青立体图像。原始照片由埃琳娜·亚当斯拍摄。
图 3-8 显示了图 2-28 所示串行代码中的数据和控制依赖关系。数据依赖关系显示为实线,控制依赖关系显示为虚线。从这个图中,我们可以看到对getLeftImage
和increasePNGChannel
的调用并不依赖于对getRightImage
和increasePNGChannel
的调用。因此,这两个系列的调用可以彼此并行进行。我们还可以看到,mergePNGImages
无法继续,直到左右图像上的increasePNGChannel
都已完成。最后,write
必须等到对mergePNGImages
的调用结束。
与第二章不同,在第二章中,我们使用线性管道,使用 TBB 流图,我们现在可以更准确地表达依赖性。为此,我们需要首先理解应用程序中保持正确执行的约束。例如,while 循环的每次迭代直到前一次迭代完成后才开始,但这可能只是使用串行 while 循环的副作用。我们需要确定哪些约束是真正必要的。
图 3-8。
图 2-28 中代码示例的控制和数据依赖,其中实线代表数据依赖,虚线代表控制依赖
在这个例子中,让我们假设图像代表从文件或照相机中按顺序读取的帧。由于图像必须按顺序读取,我们不能同时多次调用getLeftImage
或getRightImage
;这些是串行操作。然而,我们可以将对getLeftImage
的调用与对getRightImage
的调用重叠,因为这些函数不会相互干扰。除了这些约束,我们将假设increasePNGChannel
、mergePNGImages
和write
在不同的输入上并行执行是安全的(它们都是无副作用和线程安全的)。因此,while 循环的迭代不能完全并行执行,但是只要保留这里确定的约束,我们就可以在迭代内部和迭代之间利用一些并行性。
将示例实现为 TBB 流程图
现在,让我们逐步完成实现我们的立体 3D 样本的 TBB 流图的构造。我们将要创建的流程图的结构如图 3-9 所示。这个图看起来与图 3-8 不同,因为现在节点代表 TBB 流图节点对象,边代表 TBB 流图边。
图 3-9。
表示图 2-28 中调用的图表。圆圈封装了图 2-28 中的功能。边缘代表中间值。梯形表示将消息连接成二元组的节点。
图 3-10 显示了使用 TBB 流程图接口实现的立体 3D 示例。方框中概述了五个基本步骤。首先,我们创建一个图形对象。接下来,我们创建八个节点,包括一个source_node
、几个function_node
实例和一个join_node
。然后,我们使用对make_edge
的调用来连接节点。在创建边之后,我们激活源节点。最后,我们等待图形完成。
在图 3-9 的图表中,我们看到frame_no_node
是图表的输入源,在图 3-10 中,该节点使用source_node
实现。只要一个source_node
的主体继续返回true
,运行时库就会继续衍生出新的任务来执行它的主体,进而调用getNextFrameNumber()
。
正如我们前面提到的,getLeftImage
和getRightImage
函数必须串行执行。在图 3-10 的代码中,我们通过将这些节点的并发约束设置为flow::serial
来将该约束传达给运行时库。对于这些节点,我们使用类function_node
。你可以在附录 b 中看到更多关于function_node
的细节。如果一个节点用flow::serial
声明,运行时库将不会产生下一个任务来执行它的主体,直到任何未完成的主体任务完成。
图 3-10。
作为 TBB 血流图的立体 3D 例子
相比之下,increase_left_node
和increase_rigt_node
对象是用flow::unlimited.
的并发约束构造的,无论何时有消息到达,运行时库都会立即生成一个任务来执行这些节点的主体。
在图 3-9 中,我们看到merge_images_node
函数需要一个右图像和一个左图像。在最初的串行代码中,我们确保图像来自同一帧,因为 while 循环一次只对一帧进行操作。然而,在我们的流程图版本中,多个帧可以通过流程图流水线化,因此可以同时进行。因此,我们需要确保只合并对应于同一帧的左右图像。
为了给我们的merge_images_node
提供一对匹配的左右图像,我们用tag_matching
策略创建了join_images_node
。你可以在附录 b 中了解join_node
及其不同的策略。在图 3-10 中,join_images_node
被构造为具有两个输入端口,并基于匹配其frameNumber
成员变量创建一个Image
对象元组。对构造器的调用现在包括两个 lambda 表达式,用于从两个输入端口上的传入消息中获取标记值。merge_images_node
接受一个元组并生成一个合并的图像。
图 3-10 中创建的最后一个节点是write_node
。接收Image
对象并调用write
将每个传入缓冲区存储到输出文件的是一个flow::unlimited function_node
。
一旦构建完成,节点通过调用make_edge
相互连接,创建如图 3-9 所示的拓扑。我们应该注意,只有一个输入或输出的节点不需要指定端口。然而,对于像join_images_node
这样有多个输入端口的节点,端口访问器函数用于将特定的端口传递给make_edge
调用。
最后,在图 3-10 中,frame_no_node
被激活,调用wait_for_all
来等待图形完成执行。
了解数据流图的性能
值得注意的是,与其他一些数据流框架不同,TBB 流图中的节点不是作为线程实现的。相反,当消息到达节点并且并发限制允许时,TBB 任务被反应性地产生。一旦任务产生,它们就被调度到 TBB 工作线程上,使用与 TBB 通用算法相同的工作窃取方法(参见第九章了解工作窃取调度器的详细信息)。
有三个主要因素会限制 TBB 流图的性能:(1)串行节点,(2)工作线程的数量,以及(3)并行执行 TBB 任务的开销。
让我们考虑如何将我们的 3D 立体图形映射到 TBB 任务,以及如何执行这些任务。节点frame_no_node
、get_left_node
和get_right_node
是flow::serial
节点。剩下的节点是flow::unlimited
。
串行节点会导致工作线程空闲,因为它们限制了任务的可用性。在我们的立体 3D 示例中,按顺序读取图像。一旦每个图像被读取,图像的处理可以立即开始,并且可以与系统中的任何其他工作重叠。因此,这三个串行节点是我们图中限制任务可用性的节点。如果读取这些图像的时间支配了其余的处理,我们将看到很少的加速。然而,如果处理时间比读取图像的时间长得多,我们可能会看到明显的加速。
如果图像读取不是我们的限制因素,那么性能就会受到工作线程数量和并行执行开销的限制。当我们使用流程图时,我们在可能在不同工作线程上执行的节点之间传递数据,同样,在处理器内核上也是如此。我们还重叠不同功能的执行。跨线程传递数据和在不同线程上同时执行函数都会影响内存和缓存行为。我们将在本书的第二部分更详细地讨论局部性和开销优化。
依赖图的特例
TBB 流图接口支持数据流和依赖图。数据流图中的边是数据在节点之间传递的通道。我们在本章前面构建的立体 3D 示例是数据流图的一个示例—Image
对象在图中从一个节点到另一个节点的边上通过。
依赖图中的边表示正确执行必须满足的前后关系。在依赖图中,数据通过共享内存从一个节点传递到另一个节点,而不是通过边上的消息直接传递。图 3-11 显示了制作花生酱和果冻三明治的依赖关系图;边传达了一个节点直到其所有的完成后才能开始。
**
图 3-11。
制作花生酱和果冻三明治的依赖图。这里的边代表前后关系。
为了使用 TBB 流图类来表达依赖图,我们使用类continue_node
作为节点并传递类型continue_msg
的消息。function_node
和continue_node
的主要区别在于它们对信息的反应。你可以在附录 b 中看到continue_node
的细节
当一个function_node
接收到一个消息时,它将它的主体应用于该消息——要么立即产生一个任务,要么缓冲该消息直到合法产生一个任务来应用主体。相比之下,continue_node
计算它接收的消息数量。当它接收到的消息数等于它拥有的前辈的数量时,它产生一个任务来执行它的主体,然后重置它的消息接收计数。例如,如果我们使用continue_nodes
来实现图 3-11 ,那么“将切片放在一起”节点将在每次接收到两个continue_msg
对象时执行,因为它在图中有两个前置对象。
对象对消息进行计数,并且不跟踪每个单独的前任已经发送的消息。例如,如果一个节点有两个前置节点,它将在收到两个消息后执行,而不管消息来自哪里。这使得这些节点的开销更低,但也要求依赖图是非循环的。此外,虽然依赖图可以重复执行直到完成,但是将continue_msg
对象流入依赖图是不安全的。在这两种情况下,当存在循环或者如果我们将项目流式传输到依赖图中,简单的计数机制意味着节点可能会错误地触发,因为当它真正需要等待来自不同后继者的输入时,它会对从相同后继者接收的消息进行计数。
实现依赖图
使用依赖图的步骤与使用数据流图的步骤相同;我们创建一个图形对象,制作节点,添加边,并将消息输入图形。主要的区别是只使用了continue_node
和broadcast_node
类,图必须是非循环的,并且我们必须在每次向图中输入消息时等待图执行完成。
现在,让我们构建一个示例依赖图。对于我们的例子,让我们使用一个 TBB parallel_do
来实现我们在第二章中实现的同一个正向替换例子。你可以参考那一章中串行例子的详细描述。
图 3-12 再现了该示例的串行平铺实现。
图 3-12。
用于直接实现正向替换的串行阻塞代码。编写该实现是为了使算法清晰明了,而不是为了获得最佳性能。
在第二章中,我们讨论了本例中操作之间的依赖关系,并注意到,如图 3-13 所示,在计算的对角线上可以看到一个并行波前。当使用parallel_do
时,我们创建了一个原子计数器的 2D 阵列,并且必须手动跟踪每个块何时可以被安全地提供给parallel_do
算法来执行。虽然有效,但这很麻烦且容易出错。
图 3-13。
8 × 8 小矩阵正向代换中的依赖性。在(a)中,显示了迭代之间的依赖性。在(b)中,迭代被分组为块以减少调度开销。在(a)和(b)中,每个节点都必须等待它上面的邻居和它左边的邻居完成,然后才能执行。
在第二章的中,我们注意到在这个例子中我们也可以使用一个parallel_reduce
来表达并行性。我们可以在图 3-14 中看到这样的实现。
图 3-14。
使用parallel_reduce
进行正向并行替换
然而,正如我们在图 3-15 中看到的,主线程必须等待每个parallel_reduce
完成,然后才能继续下一个。行之间的这种同步增加了不必要的同步点。例如,一旦块 1,0 完成,立即开始处理 2,0 是安全的,但是我们必须等到 fork-join parallel_reduce
算法完成,直到我们移动到那一行。
图 3-15。
主线程必须等待每个parallel_reduce
完成,然后才能移动到下一个parallel_reduce
,引入同步点
使用依赖图,我们简单地直接表达依赖关系,并允许 TBB 库发现和利用图中可用的并行性。我们不必像第二章中的parallel_do
版本那样明确地维护计数或跟踪完成,我们也不会像图 3-14 那样引入不必要的同步点。
图 3-16 显示了该示例的依赖图版本。我们使用一个std::vector nodes
来保存一组continue_node
对象,每个节点代表一个迭代块。为了创建图形,我们遵循常见的模式:(1)创建图形对象,(2)创建节点,(3)添加边,(4)向图形中输入消息,以及(5)等待图形完成。然而,我们现在使用循环嵌套创建图结构,如图 3-16 所示。函数createNode
为每个块创建一个新的continue_node
对象,函数addEdges
将节点连接到必须等待其完成的邻居。
图 3-16。
正向替换示例的依赖图实现
在图 3-17 中,我们展示了createNode.
的实现。
图 3-17。
createNode
功能实现
在createNode
中创建的continue_node
对象使用一个 lambda 表达式,该表达式封装了图 3-12 中所示的前向替换的阻塞版本的两个内部循环。由于没有数据通过依赖图的边传递,每个节点需要的数据通过共享内存使用 lambda 表达式捕获的指针来访问。在图 3-17 中,节点通过值捕获整数r
、c
、N
和block_size
,以及对向量x
、a
和b
的引用。
在图 3-18 中,函数addEdges
使用make_edge
调用将每个节点连接到它的右下邻居,因为它们必须等待新节点完成后才能执行。当图 3-16 中的循环嵌套完成后,一个类似于图 3-13 中的依赖图就被构建好了。
图 3-18。
addEdges
功能实现
如图 3-16 所示,一旦构建了完整的图,我们通过向左上角的节点发送一个continue_msg
来开始它。任何没有前置任务的continue_node
都会在收到消息时执行。向左上角的节点发送消息会启动依赖图。同样,我们使用g.wait_for_all()
来等待图形执行完毕。
评估依赖图的可伸缩性
适用于数据流图的相同性能限制也适用于依赖图。然而,因为依赖图必须是非循环的,所以更容易估计它们的可伸缩性上限。在本讨论中,我们使用由麻省理工学院 Cilk 项目引入的符号(参见,例如, Blumofe,Joerg,Kuszmaul,Leiserson,Randall 和 Zhou,“Cilk:一个高效的多线程运行时系统”,并行编程的原理和实践,1995 )。
我们用 T 1
表示执行图中所有节点的时间之和;1 表示如果我们只有一个执行线程,这是执行图形所花费的时间。我们将沿着关键(最长)路径执行节点的时间表示为 T ∞ ,因为这是最小可能的执行时间,即使我们有无限数量的线程可用。通过依赖图中的并行性可实现的最大加速是 T 1
/T ∞ 。在 P 个处理器的平台上执行时,执行时间绝不能小于 T 1
/P 和 T ∞ 中的最大值。
例如,为了简单起见,让我们假设图 3-13(a) 中的每个节点花费相同的时间来执行。我们将这个时间称为t
n
。图中有 36 个节点(行数*
列数),所以T
1
= 36t
n
。从0,0
到7,7
的最长路径包含 15 个节点(行数+
列数–1),因此对于此图T
= 15t
n
。即使我们有无限数量的处理器,关键路径上的节点也必须按顺序执行,不能重叠。因此,我们对于这个小 8 ×
8 图的最大加速是36t
n
/15t
n
= 2.4
。然而,如果我们有一个更大的方程组要解,让我们假设一个512×512
矩阵,沿着关键路径将有512×512=131,328
节点和512+512-1=1023
节点,对于131,328/1023
≈ 128
的最大加速。
如果可能,如果您正在考虑实现串行应用程序的依赖图版本,那么分析您的串行代码、收集每个潜在节点的时间并估计关键路径长度是一个很好的实践。然后,您可以使用前面描述的简单计算来估计可实现的加速上限。
TBB 流图的高级主题
TBB 流图有一组丰富的节点和接口,我们在这一章才刚刚开始触及这个表面。在第十七章中,我们更深入地研究 API 来回答一些重要的问题,包括
-
我们如何在流程图中控制资源的使用?
-
我们什么时候需要使用缓冲?
-
有需要避免的反模式吗?
-
有没有有效的模式可以模仿?
此外,流程图支持异步和异构的能力,我们将在第 18 和 19 章中探讨。
摘要
在这一章中,我们学习了让我们开发数据流和依赖图的tbb::flow namespace
中的类和函数。我们首先讨论了为什么用图来表达并行性是有用的。然后,我们学习了 TBB 流图界面的基础知识,包括界面中可用的不同节点类别的简要概述。接下来,我们一步一步地构建了一个小型数据流图,该图将 3D 立体效果应用于左右图像集。之后,我们讨论了如何将这些节点映射到 TBB 任务,以及流图的性能限制是什么。接下来,我们看了依赖图,这是数据流图的一个特例,其中边传递依赖消息而不是数据消息。我们还构建了一个向前替换的例子作为依赖图,并讨论了如何估计它的最大加速比。最后,我们提到了一些重要的高级主题,这些主题将在本书的后面部分讨论。
图 2-28a 、 2-29 和 3-7 中使用的照片由 Elena Adams 拍摄,经 Halide 项目教程 http://halide-lang.org
许可使用。
开放存取本章根据知识共享署名-非商业-非专用 4.0 国际许可协议(http://Creative Commons . org/licenses/by-NC-nd/4.0/)的条款进行许可,该协议允许以任何媒体或格式进行任何非商业使用、共享、分发和复制,只要您适当注明原作者和来源,提供知识共享许可协议的链接,并指出您是否修改了许可材料。根据本许可证,您无权共享从本章或其部分内容派生的改编材料。
本章中的图像或其他第三方材料包含在该章的知识共享许可中,除非该材料的信用额度中另有说明。如果材料未包含在本章的知识共享许可中,并且您的预期用途不被法定法规允许或超出了允许的用途,您将需要直接从版权所有者处获得许可。**
四、TBB 和 C++ 标准模板库的并行算法
为了有效地使用线程构建模块(TBB)库,了解它如何支持和扩充 C++ 标准是很重要的。在本章中,我们讨论了 TBB 与标准 C++ 关系的三个方面:
-
TBB 库经常包含 C++ 标准中新增的与并行性相关的特性。在 TBB 中包含这样的特性可以让开发人员在它们被广泛应用于所有编译器之前就可以提前使用它们。在这种情况下,所有预构建的 TBB 发行版现在都包含了英特尔对 C++ 标准模板库(STL)并行算法的实现。这些实现使用 TBB 任务来实现多线程,使用 SIMD 指令来实现向量化。本章主要讨论并行 STL。
-
TBB 库还提供了一些 C++ 标准中没有的特性,但是让开发人员更容易表达并行性。通用并行算法和流程图就是这样的例子。在这一章中,我们将讨论 TBB 中包含的自定义迭代器,它拓宽了并行 STL 算法的应用范围。
-
最后,我们在本章中注意到,对 C++ 标准的一些补充可能会取代对某些 TBB 特性的需求。然而,我们也注意到,在可预见的未来,TBB 的价值可能不会被 C++ 标准所包含。例如,TBB 提供的将持续受益的特性包括它的工作窃取任务调度器、线程安全容器、流图 API 和可伸缩内存分配器。
C++ STL 库属于这本书吗?
关于 C++ 标准模板库的一章真的属于一本关于 TBB 的书吗?是的,确实如此!TBB 是一个并行的 C++ 库,它不存在于真空中。我们需要理解它与 C++ 标准的关系。
我们在本章中讨论的执行策略在某些方面类似于第二章中介绍的 TBB 并行算法,因为它们让我们表达了并行执行算法是安全的——但是它们 没有 规定确切的实现细节。如果我们想在一个应用程序中混合 TBB 算法和并行 STL 算法,并且仍然拥有高效、可组合的并行性(参见第九章),我们可以从使用 TBB 作为并行执行引擎的并行 STL 实现中获益!因此,当我们在本章中讨论并行执行策略时,我们将关注基于 TBB 的实现。当我们使用一个底层使用 TBB 的并行 STL 时,并行 STL 就变成了我们在代码中使用 TBB 任务的另一个途径。
回到第一章中的图 1-3 ,我们注意到许多应用都有多级并行可用,包括最适合在矢量单元上执行的单指令多数据(SIMD)层。正如图 4-1 中所示二项式期权应用的性能结果所示,利用这种级别的并行性至关重要。向量并行在单独使用时只能提高很小一部分性能;它受到向量宽度的限制。然而,图 4-1 提醒我们,不应该忽视同时使用任务并行和向量并行的倍增效应。
图 4-1
二项式期权定价应用程序在串行、矢量化、线程化以及矢量化和线程化执行时的性能
在第一章中,我们实现了一个示例,该示例使用顶级 TBB 流图形层来引入线程,在图形节点中嵌套通用 TBB 并行算法来获得更多线程,然后嵌套 STL 算法,该算法在并行算法体中使用矢量策略来引入矢量化。当我们将 TBB 与并行 STL 及其执行策略相结合时,我们不仅获得了可组合的消息传递和 fork-join 层,还获得了对 SIMD 层的访问。
正是由于这些原因,STL 库中的执行策略是我们探索 TBB 的重要部分!
TBB 和 C++ 标准
开发 TBB 的团队是 C++ 语言本身支持线程的强烈支持者。事实上,TBB 经常包括模仿 C++ 中标准化的并行特性,以允许开发人员在主流编译器广泛支持这些接口之前迁移到这些接口。这方面的例子是std::thread
。TBB 的开发人员认识到了std::thread
的重要性,因此在它在所有 C++ 标准库中可用之前,就为开发人员提供了一个可移植的实现,将该特性直接注入到了std
名称空间中。今天,TBB 对std::thread
的实现简单地包括了平台对std::thread
的实现(如果有的话),并且只有当平台的标准 C++ 库不包括实现时才回退到它自己的实现。对于其他现在标准的 C++ 特性,如原子变量、互斥对象和std::condition_variable
,也有类似的情况。
并行 STL 执行策略模拟
为了帮助思考并行 STL 库提供的不同执行策略,我们可以想象一条多线高速公路,如图 4-2 所示。与大多数类比一样,这并不完美,但它可以帮助我们看到不同政策的好处。
我们可以将多车道高速公路中的每条车道视为一个执行线程,将每个人视为一个要完成的操作(例如,这个人需要从 A 点到 B 点),将每辆汽车视为一个处理器内核,将汽车中的每个座位视为(向量)寄存器中的一个元素。在串行执行中,我们只使用高速公路的一条车道(单线程),每个人都有自己的车(我们没有使用矢量单元)。无论人们是否在同一条路线上行驶,他们都各自开着自己的车,在同一条车道上行驶。
图 4-2
并行 STL 中执行策略的多线高速公路模拟
在一个线程执行中,我们使用了不止一条高速公路车道(即不止一个执行线程)。现在,我们在单位时间内完成了更多的任务,但是仍然不允许拼车。如果几个人从同一个起点出发,前往同一个目的地,他们各自开自己的车。我们正在更有效地利用高速公路,但我们的汽车(核心)正在被低效使用。
一个矢量化执行就像拼车。如果几个人需要走完全相同的路线,他们共用一辆车。许多现代处理器支持向量指令,例如英特尔处理器中的 SSE 和 AVX。如果我们不使用向量指令,我们就没有充分利用我们的处理器。这些内核中的矢量单元可以同时对多段数据应用相同的操作。向量寄存器中的数据就像人们共用一辆汽车,他们走完全相同的路线。
最后,线程化和矢量化的执行就像使用高速公路上的所有车道(所有内核)以及拼车(使用每个内核中的矢量单元)。
使用std:
和:for_each
的简单例子
现在我们已经对执行策略有了一个大致的概念,但是在我们进入所有血淋淋的细节之前,让我们从对 vector v
中的所有元素应用一个函数void f(float &e)
开始,如图 4-3(a) 所示。使用 C++ STL 库中的算法之一std::for_each
,我们也可以做同样的事情,如图 4-3(b) 。就像基于范围的for
,for_each
从v.begin()
迭代到v.end()
,并对向量中的每一项调用 lambda 表达式。这是for_each
的默认顺序行为。
然而,使用并行 STL,我们可以通知库,为了利用并行性,可以放松这些语义,或者如图 4-3(c) 所示,我们可以让库明确知道我们需要序列语义。使用英特尔的并行 STL 时,我们需要在代码中包含算法和执行策略头,例如:
在 C++17 中,省略执行策略或者传入sequenced_policy
对象seq
,会导致相同的默认执行行为:它看起来就好像lambda 表达式按顺序在 vector 中的每一项上被调用。我们说“好像”是因为硬件和编译器被允许并行化算法,但前提是这样做对符合标准的程序是不可见的。
并行 STL 的强大之处来自于放松了这种顺序约束的其他执行策略。我们说,通过使用unsequenced_policy
对象unseq
,操作可以从一个执行的单线程中重叠或矢量化,如图 4-3(d) 所示。然后,该库可以在单线程中重叠操作,例如,通过使用 SSE 或 AVX 等单指令多数据(SIMD)扩展来矢量化执行。图 4-4 显示了这种行为,使用并排的方框来表示这些操作使用矢量单位同时执行。unseq
执行政策允许“拼车”
图 4-4
使用不同的执行策略应用操作
图 4-3
用std::for_each
实现的简单循环,使用各种并行 STL 执行策略
在图 4-3(e) 中,我们告诉库,使用parallel_policy
对象、par
的多线程执行,在 vector 中的所有元素上执行这个函数是安全的。如图 4-4 所示,par
策略允许操作分布在不同的执行线程上,但是,在每个线程内,操作不会重叠(即,它们不会被矢量化)。回想一下我们的多车道高速公路的例子,我们现在使用高速公路上的所有车道,但还没有拼车。
最后,在图 4-3(f) 中,parallel_unsequenced_policy
对象,par_unseq
用于传达 lambda 表达式对元素的应用既可以并行化也可以矢量化。在图 4-4 中,par_unseq
的执行使用了多个执行线程和在每个线程内重叠操作。我们现在充分利用了平台中的所有内核,并通过利用每个内核的向量单元来有效地利用每个内核。
在实践中,我们在使用执行策略时必须小心。就像一般的 TBB 并行算法一样,当我们使用执行策略来放松 STL 算法的执行顺序时,我们向库声明这种放松是合法且有利可图的。图书馆不检查我们是正确的。同样,该库也不能保证使用某种执行策略不会降低性能。
图 4-3 中需要注意的另一点是,STL 算法本身在名称空间std
中,但是由英特尔的并行 STL 提供的执行策略在名称空间pstl::execution
中。如果您有一个完全兼容的 C++17 编译器,那么如果您在std::execution
名称空间中使用标准执行策略,将会选择其他可能不使用 TBB 的实现。
并行 STL 实现中提供了哪些算法?
C++ 标准模板库(STL)主要包括应用于序列的操作。有一些异常值,如std::min
和std::max
,可以应用于值,但在大多数情况下,算法,如std::for_each
、std::find, std::transform, std::copy,
和std::sort
,应用于项目序列。当我们想要在支持迭代器的容器上操作时,这种对序列的关注是很方便的,但是如果我们想要表达一些不能在容器上操作的东西,这就有点麻烦了。在这一章的后面,我们会看到有时我们可以“跳出框框思考”,使用自定义迭代器使一些算法的行为更像一般的循环。
解释每个 STL 算法做什么超出了本章和本书的范围。有很多关于 C++ 标准模板库以及如何使用它的书籍,包括 Nicolai Josuttis(Addison-Wesley Professional)的《C++ 标准库:教程和参考》。在本章中,我们只关注在 C++17 中首次引入的执行策略对这些算法意味着什么,以及它们如何与 TBB 一起使用。
C++ 标准中规定的大多数 STL 算法在 C++17 中都有接受执行策略的重载。此外,增加了一些新算法,因为它们在并行程序中特别有用,或者因为委员会希望避免语义上的变化。我们可以通过查看标准本身或在类似 http://en.cppreference.com/w/cpp/algorithm
的网站上找到支持执行策略的算法。
如何获得和使用一个使用 TBB 的并行 STL 副本
“获取线程构建模块(TBB)库”一节中的第一章提供了下载和安装英特尔并行 STL 的详细说明如果你下载并安装了 TBB 2018 update 5 或更高版本的预建副本,无论是通过英特尔获得的商业许可副本还是从 GitHub 下载的开源二进制分发,那么你也会获得英特尔的并行 STL。并行 STL 附带了所有预构建的 TBB 包。
但是,如果您想从 GitHub 获得的源代码构建 TBB 库,那么您需要从 GitHub 单独下载并行 STL 源代码,因为这两个库的源代码分别保存在不同的库 https://github.com/intel/tbb
和 https://github.com/intel/parallelstl
中。
正如我们已经看到的,并行 STL 支持几种不同的执行策略,有些支持并行执行,有些支持矢量化执行,有些两者都支持。英特尔的并行 STL 支持 TBB 并行和使用 OpenMP 4.0 SIMD 结构的矢量化。为了充分利用英特尔的并行 STL,你必须拥有一个支持 C++11 和 OpenMP 4.0 SIMD 结构的 C++ 编译器——当然你还需要 TBB。我们强烈建议使用任何版本的英特尔 Parallel Studio XE 2018 或更高版本附带的英特尔编译器。这些编译器不仅包括 TBB 库并支持 OpenMP 4.0 SIMD 结构,还包括专门用于提高某些 C++ STL 算法在使用unseq
或par_unseq
执行策略时的性能的优化。
要构建一个在命令行使用并行 STL 的应用程序,我们需要为编译和链接设置环境变量。如果我们安装了英特尔 Parallel Studio XE,我们可以通过调用套件级环境脚本(如compilervars.{sh|csh|bat}
)来实现。如果我们刚刚安装了并行 STL,那么我们可以通过在<pstl_install_dir>/{linux|mac|windows}/pstl/bin.
中运行pstlvars.{sh|csh|bat}
来设置环境变量,额外的说明在第一章中提供。
英特尔并行 STL 中的算法
英特尔的并行 STL 还不支持每个 STL 算法的所有执行策略。可以在 https://software.intel.com/en-us/get-started-with-pstl
找到该库提供的算法以及每个算法支持的策略的最新列表。
图 4-5 显示了本书撰写时所支持的算法和执行策略。
图 4-5
截至 2019 年 1 月,英特尔并行 STL 中支持执行策略的算法。以后可能会支持其他算法和策略。更新见 https://software.intel.com/en-us/get-started-with-pstl
。
图 4-6 显示了英特尔并行 STL 所支持的政策,包括那些属于 C++17 标准的政策,以及那些被提议纳入未来标准的政策。C++17 策略允许我们选择顺序执行(seq
)、使用 TBB 的并行执行(par
)或使用也是矢量化的 TBB 的并行执行(par_unseq
)。unsequenced ( unseq
)策略让我们选择一个仅矢量化的实现。
图 4-6
英特尔并行 STL 支持的执行策略
用定制迭代器捕获更多用例
在本章的前面,我们介绍了std::for_each
的一个简单用法,并展示了不同的执行策略如何与它一起使用。我们用图 4-3(f) 中的par_unseq
的简单例子看起来像
乍一看,for_each
算法似乎相当有限,它访问序列中的元素,并对每个元素应用一元函数。当以这种预期的方式在容器上使用时,它实际上在适用性上受到限制。例如,它不接受像 TBB parallel_for
这样的范围。
然而,C++ 是一种强大的语言,我们可以创造性地使用 STL 算法来扩展它们的适用性。正如我们在第二章中所讨论的,迭代器是一个对象,它指向一个元素范围中的一个元素,并定义提供遍历该范围中的元素的能力的操作符。迭代器有不同的类别,包括正向、双向和随机访问迭代器。许多标准 C++ 容器提供了返回迭代器的begin
和end
函数,让我们遍历容器的元素。将 STL 算法应用于更多用例的一种常见方式是使用定制的迭代器。这些类实现迭代器接口,但不包含在 C++ 标准模板库中。
TBB 库中包含了三个常用的自定义迭代器来帮助使用 STL 算法。这些迭代器类型在图 4-7 中有描述,并且可以在iterators.h
头文件中或者通过全包tbb.h
头文件获得。
图 4-7
TBB 提供的自定义迭代器类
例如,我们可以将自定义迭代器传递给std::for_each
,使其更像一个普通的for
循环。让我们考虑图 4-8(a) 所示的简单循环。对于范围[0,n)
中的每个i
,该循环将a[i]+b[i]*b[i]
写回a[i]
。
图 4-8
使用定制迭代器的std::for_each
在图 4-8(b) 中,counting_iterator
类用于创建类似范围的东西。传递给for_each
λ表达式的参数将是从0
到n-1
的整数值。尽管for_each
仍然只在单个序列上迭代,我们使用这些值作为两个向量a
和b
的索引。
在图 4-8(c) 中,zip_iterator
类用于同时迭代a
和b
向量。TBB 库提供了一个make_zip_iterator
函数来简化迭代器的构造:
在图 4-8(c) 中,我们仍然在对for_each
的调用中只使用了一个序列。但是现在,传递给 lambda 表达式的参数是对float
的引用的std::tuple
,每个向量一个。
最后,在图 4-8(d) 中,我们添加了transform_iterator
类的用法。我们首先使用zip_iterator
类将来自向量a
和b
的两个序列合并成一个序列,就像我们在图 4-8(c) 中所做的那样。但是,我们也创建了一个 lambda 表达式,并将其赋值给square_b
。lambda 表达式将用于转换对float
的引用的std::tuple
,这些引用是通过解引用zip_iterator.
获得的。我们将此 lambda 表达式传递给对make_tranform_iterator
函数的调用:
当图 4-8(d) 中的transform_iterator
对象被解引用时,它们从底层zip_iterator
接收一个元素,对元组的第二个元素求平方,并创建一个新的std::tuple
,它包含对来自a
的float
和来自b
的平方值的引用。传递给for_each
lambda 表达式的参数包含一个已经平方的值,因此该函数不需要计算b[i]*b[i].
因为像图 4-7 中那样的自定义迭代器非常有用,它们不仅可以在 TBB 库中获得,还可以通过其他库获得,比如 Boost C++ 库( www.boost.org
)和 Thrust ( https://thrust.github.io/doc/group__fancyiterator.html
)。它们目前不能直接在 C++ 标准模板库中获得。
重点介绍一些最有用的算法
准备工作结束后,我们现在可以更深入地讨论并行 STL 提供的更有用的通用算法,包括for_each
、transform
、reduce
和transform_reduce
。当我们讨论每种算法时,我们指出了 TBB 通用算法中的相似之处。与 TBB 特定的接口相比,并行 STL 接口的优势在于并行 STL 是 C++ 标准的一部分。并行 STL 接口的缺点是,与一般的 TBB 算法相比,它们的表达能力和可调性较差。当我们在本节中讨论算法时,我们会指出其中的一些缺点。
std:
:for_each
,std:
:for_each_n
在这一章中我们已经谈了很多关于for_each
的内容。除了for_each,
并行 STL 还提供了一个for_each_n
算法,只访问第一个n
元素。算法for_each
和for_each_n
都有几个接口;接受执行策略的如下:
结合定制迭代器,for_each
可以非常有表现力,正如我们在图 4-8 中所展示的。例如,我们可以从第二章中提取简单的矩阵乘法示例,并使用counting_iterator
类在图 4-9 中重新实现它。
图 4-9
使用std::for_each
和tbb::counting_iterator
创建矩阵乘法的并行版本
如果我们使用一个底层使用 TBB 的 STL,比如英特尔的并行 STL,par
策略是使用tbb::parallel_for
实现的,因此对于这样一个简单的例子来说,std::for_each
和tbb::parallel_for
的性能将是相似的。
这当然引出了一个问题。如果std::for_each
使用一个tbb::parallel_for
来实现它的par
策略,但是它是一个标准接口,并且也给我们访问其他策略的权限,难道我们不应该总是使用std::for_each
而不是tbb::parallel_for
吗?
不幸的是,不是所有的代码都像这个例子一样简单。如果我们对有效的线程实现感兴趣,通常直接使用tbb::parallel_for
会更好。即使对于这个矩阵乘法的例子,正如我们在第二章中提到的,我们的简单实现也不是最佳的。在本书的第二部分,我们讨论了 TBB 中可用的重要优化钩子,我们可以用它们来调优我们的代码。我们将在第十六章中看到这些钩子会带来显著的性能提升。不幸的是,当我们使用并行 STL 算法时,这些高级特性中的大部分都无法应用。标准的 C++ 接口根本不允许它们。
当我们使用一个并行 STL 算法并选择一个标准策略如par, unseq
或par_unseq
时,我们得到实现决定给我们的任何东西。有人提议在 C++ 中增加一些东西,比如 executors,将来可能会解决这个问题。但目前,我们对 STL 算法几乎没有控制权。当使用 TBB 遗传算法时,比如parallel_for
,我们可以使用本书第二部分描述的丰富的优化特性,比如划分器、不同类型的阻塞范围、粒度、相似性提示、优先级、隔离特性等等。
对于一些简单的情况,一个标准的 C++ 并行 STL 算法可能和它的 TBB 版本一样好,但是在更现实的场景中,TBB 为我们提供了获得我们想要的性能所需的灵活性和控制。
std:
:transform
并行 STL 中另一个有用的算法是transform
。它对一个序列中的元素应用一元运算,对两个输入序列中的元素应用二元运算,并将结果写入单个输出序列中的元素。支持并行执行策略的两个接口如下:
在图 4-8 中,我们使用for_each
和自定义迭代器从两个向量中读取并写回一个输出向量,在每次迭代中计算a[i] = a[i] + b[i]*b[i]
。正如我们在图 4-10 中看到的,这是std::transform
的一个很好的候选。因为transform
有一个支持两个输入序列和一个输出序列的接口,这与我们的例子非常匹配。
图 4-10
使用std::transform
将两个向量相加
与std::for_each
一样,当以典型方式使用时,该算法的适用性受到限制,因为最多有两个输入序列,只有一个输出序列。如果我们有一个写不止一个输出数组或容器的循环,用一个单独的 transform 调用来表达它是不方便的。当然,这是可能的——几乎任何东西都在 C++ 中——但是它需要使用自定义迭代器,比如zip_iterator
,以及一些非常难看的代码来访问许多容器。
std:
:reduce
我们在第二章讨论tbb::parallel_reduce
时讨论了缩减。并行 STL 算法reduce
对一个序列的元素进行约简。然而与tbb::parallel_reduce
不同的是,
它只提供了一个归约操作。在下一节中,我们将讨论transform_reduce
,它更像tbb::parallel_reduce
,因为它同时提供了转换操作和归约操作。支持并行执行策略的std::reduce
的两个接口如下:
reduce
算法使用binary_op
和恒等值init
执行序列元素的广义求和。在第一个界面中,binary_op
不是输入参数,默认使用std::plus<>
。广义和意味着元素可以按任意顺序分组和重新排列的归约——因此该算法假设运算是结合的和交换的。正因为如此,我们可能会遇到我们在第二章的边栏中讨论过的浮点舍入问题。
如果我们想对向量中的元素求和,我们可以使用std::reduce
和任何执行策略,如图 4-11 所示。
图 4-11
使用std::reduce
对向量的柠檬求和四次
标准::转换 _ 减少
如前一节所述,transform_reduce
类似于tbb::parallel_reduce
,因为它提供了变换操作和归约操作。但是,与大多数 STL 算法一样,它一次只能应用于一个或两个输入序列:
我们可以用std::transform_reduce
实现的一个重要且常见的内核是内积。这种用法非常普遍,以至于有一个接口在默认情况下使用std::plus<>
和std::multiplies<>
进行两种操作:
两个向量a
和b
的内积的串行码如图 4-12(a) 所示。我们可以使用一个std::transform_reduce
,并为这两个操作提供我们自己的 lambda 表达式,如图 4-12(b) 所示。或者,如图 4-12(c) 所示,我们可以依赖默认操作。
图 4-12
使用std::transform_reduce
计算内积
同样,与其他并行 STL 算法一样,如果我们稍微跳出框框思考,我们可以使用自定义迭代器,比如counting_iterator
,使用这种算法来处理不仅仅是容器中的元素。例如,我们可以拿我们在第二章中用tbb::parallel_reduce
实现的圆周率的计算例子,用一个std::transform_reduce
来实现,如图 4-13 所示。
图 4-13
使用std::transform_reduce
计算圆周率
使用类似于std::transform_reduce
的并行 STL 算法而不是tbb::parallel_reduce
带有和我们描述的其他算法一样的优点和缺点。它使用一个标准化的接口,因此具有更好的可移植性。然而,它不允许我们使用本书第二部分中描述的优化特性来优化它的性能。
对执行策略的深入探究
并行 STL 中的执行策略让我们在 STL 算法的执行过程中交流我们想要应用于操作排序的约束。标准策略集不是凭空而来的,它捕捉了执行高效并行/线程或 SIMD/矢量化代码所需的宽松约束。
如果您乐意将sequenced_policy
理解为顺序执行、parallel_policy
理解为并行执行、unsequenced_policy
理解为向量化执行、parallel_unsequenced_policy
理解为并行和向量化执行,那么您可以跳过本节的其余部分。然而,如果你想理解这些政策所隐含的微妙之处,请继续阅读我们深入探讨的细节。
sequenced_policy
sequenced_policy
意味着一个算法的执行看起来好像 (1)该算法使用的所有元素访问函数都在调用该算法的线程上被调用,以及(2)元素访问函数的调用不是交错的(即,它们在一个给定的线程内相对于彼此被排序)。元素访问函数是在访问元素的算法期间调用的任何函数,例如迭代器中的函数、比较或交换函数,以及应用于元素的任何其他用户提供的函数。如前所述,我们说“好像”是因为硬件和编译器被允许违反这些规则,但前提是这样做对符合标准的程序是不可见的。
需要注意的一点是,许多 STL 算法并没有指定操作以任何特定的顺序应用,即使是在有序的情况下。例如,虽然std::for_each
指定了序列的元素在有序的情况下按顺序访问,但是std::transform
没有。std::transform
访问一个序列中的所有元素,但不是以任何特定的顺序。除非另有说明,有序执行意味着元素访问函数的调用在调用线程中是不确定有序的 ??。如果两个函数调用是“不确定顺序的”,这意味着其中一个函数调用在另一个函数调用开始执行之前执行完毕——但是哪个函数调用先执行并不重要。结果是,库可能无法交叉执行这两个函数的操作,例如,阻止了 SIMD 操作的使用。
“好像”规则有时会导致意想不到的性能结果。例如,sequenced_policy
执行可能和unsequenced_policy
执行得一样好,因为编译器对两者都进行了矢量化。如果得到令人困惑的结果,您可能需要检查编译器的优化报告,看看应用了哪些优化。
并行政策
parallel_policy
允许在调用线程中或从库创建的其他线程中调用元素访问函数,以帮助并行执行。然而,来自同一线程内的任何调用都是相对于彼此排序的,也就是说,同一线程上的访问函数的执行不能交错。
当我们使用英特尔的并行 STL 库时,parallel_policy
是使用 TBB 通用算法和任务实现的。执行操作的线程是主线程和 TBB 工作线程。
unsequenced_policy
unsequenced_policy
断言所有的元素访问函数都必须从调用线程中调用。然而,在调用线程中,这些函数的执行可以交错进行。顺序约束的这种放松是重要的,因为它允许库将不同函数调用中的操作聚集到单个 SIMD 指令中,或者重叠操作。
SIMD 并行可以用通过汇编代码、编译器内部函数或编译器编译指令引入的向量指令来实现。在英特尔的并行 STL 实现中,该库使用 OpenMP SIMD 编译指令。
因为元素访问函数的执行可以在单个线程中交错进行,所以在其中使用互斥对象是不安全的(互斥对象在第五章中有更详细的描述)。例如,想象一下,在执行任何匹配的解锁操作之前,交错来自不同函数的几个锁定操作。
并行未排序策略
正如我们在了解了前面的策略后所猜测的那样,parallel_unsequenced_policy
以两种方式削弱了执行约束:(1)元素访问函数可以由调用线程或其他创建来帮助并行执行的线程调用,以及(2)每个线程内的函数执行可以是交错的。
我们应该使用哪种执行策略?
当我们选择一个执行策略时,我们首先必须确保它不会将约束放松到算法计算的值是错误的程度。
例如,我们可以使用一个std::for_each
来计算一个向量a
的a[i] = a[i] + a[i-1]
,但是代码依赖于for_each
的排序顺序(与其他一些不确定排序的算法不同,它将运算符应用于按顺序排列的项目):
该示例将最后一个值存储到变量previous_value
中,该值是由 lambda 表达式通过引用捕获的。只有当我们在单个执行线程中按顺序执行操作时,这个示例才起作用。使用除了seq
之外的任何策略对象都会产生不正确的结果。
但是让我们假设我们做了尽职调查,并且我们知道哪些策略对于我们的操作和我们使用的 STL 算法是合法的。我们如何在sequenced_policy
执行、unsequenced_policy
执行、parallel_policy
执行或parallel_unsequenced_policy
执行之间做出选择?
不幸的是,没有简单的答案。但是我们可以使用一些指导方针:
-
只有当算法有足够的工作量从并行执行中获益时,我们才应该使用线程执行。我们将在本书第二部分的第十六章讨论何时使用任务的经验法则。这些规则在这里也适用。并行执行会有一些开销,如果工作量不够大,我们只会增加开销而不会提高性能。
-
矢量化的开销较低,因此可以有效地用于小型内部循环。当简单算法无法从线程化中获益时,它们可能会从矢量化中获益。
-
不过,矢量化也会有开销。要在处理器中使用向量寄存器,必须将数据打包在一起。如果我们的数据在内存中不是连续的,或者我们不能以单位步长访问它,编译器可能必须生成额外的指令来将数据收集到向量寄存器中。在这种情况下,矢量化循环的性能可能会比顺序循环差。您应该阅读编译器矢量化报告,并查看运行时配置文件,以确保添加矢量化不会使事情变得更糟。
-
因为我们可以使用并行 STL 轻松地切换策略,所以最好的选择可能是分析您的代码,看看哪种策略最适合您的平台。
引入 SIMD 并行的其他方法
除了使用 C++ STL 中的并行算法,还有几种方法可以将 SIMD 并行引入到应用程序中。最简单也是最受欢迎的方法是尽可能使用优化的特定领域或数学内核库。例如,英特尔数学内核库(英特尔 MKL)提供了许多数学函数的高度优化实现,如 BLAS、LAPACK 和 FFTW 中的那些。这些函数在有利可图的情况下利用了线程和矢量化——因此,如果我们使用这些函数,我们可以免费获得线程和矢量化。免费的好!英特尔 MLK 支持基于 TBB 的多项功能执行,因此,如果我们使用这些 TBB 版本,它们将与我们基于 TBB 的并行技术完美结合。
当然,我们可能需要实现任何预打包库中都没有的算法。在这种情况下,有三种添加向量指令的通用方法:(1)内联汇编代码,(2) simd
内部函数,以及(3)基于编译器的向量化。
我们可以使用内联汇编代码将特定的汇编指令,包括向量指令,直接注入到我们的应用程序中。这是一种依赖于编译器和处理器的低级方法,因此可移植性最差,也最容易出错。但是,它确实给了我们对所使用的指令的完全控制权(不管是好是坏)。我们使用这种方法作为最后的手段!
唯一稍微好一点的方法是使用 SIMD 内部函数。大多数编译器都提供了一组内部函数,让我们无需借助内联汇编代码就能注入特定于平台的指令。但是,除了使注入指令变得更容易之外,最终结果仍然是依赖于编译器和平台的,并且容易出错。我们通常也避免这种方法。
最后一种方法是依靠基于编译器的矢量化。在一个极端,这可能意味着完全自动化的向量化,其中我们打开正确的编译器标志,让编译器做它的事情,并希望最好的。如果成功了,那太好了!我们免费获得了矢量化的好处。记住,免费是个好东西。然而,有时我们需要给编译器一些指导,以便它能够(或将会)向量化我们的循环。有一些特定于编译器的方法来提供指导,如英特尔编译器的#pragma ivdep and #pragma vector always
和一些标准化方法,如使用 OpenMP simd
编译指令。与通过内联汇编代码或编译器内部函数将特定于平台的指令直接插入到我们的代码中相比,全自动和用户指导的编译器矢量化要容易得多。事实上,即使是英特尔的并行 STL 库也使用 OpenMP simd
编译指令以可移植的方式为unseq
和parallel_unseq
策略支持矢量化。
我们在本章末尾的“更多信息”一节中提供了一些链接,以了解有关添加矢量指令的选项的更多信息。
摘要
在这一章中,我们提供了并行 STL 的概述,它支持哪些算法和执行策略,以及如何获得一个使用线程构建块作为其执行引擎的副本。然后,我们讨论了 TBB 提供的自定义迭代器类,它们增加了 STL 算法的适用性。我们继续强调了一些最有用的和通用的并行编程算法:std::for_each
、std::transform
、std::reduce
和std::transform_reduce
。我们展示了我们在第二章中实现的一些示例也可以用这些算法来实现。但是我们也警告过,STL 算法的表达能力仍然不如 TBB,我们在本书第二部分讨论的重要性能挂钩不能用于并行 STL。虽然并行 STL 对于一些简单的情况是有用的,但是它目前的局限性使得我们不愿意将它广泛地推荐给线程。也就是说,TBB 任务并不是通向 SIMD 并行的道路。英特尔的并行 STL 提供的unseq
和parallel_unseq
策略,包含在所有最近的 TBB 发行版中,增强了 TBB 提供的线程,支持轻松矢量化。
更多信息
Vladimir Polin 和 Mikhail Dvorskiy,“并行 STL:提升 C++ STL 代码的性能:C++ 和向并行化的演进”,《并行宇宙》杂志,英特尔公司,第 28 期,第 5-18 页,2017 年。
阿列克谢·莫斯卡列夫和安德烈·费多罗夫,《并行 STL 入门》, https://software.intel.com/en-us/get-started-with-pstl
,2018 年 3 月 29 日。
Pablo Halpern,Arch D Robison,Robert 杰瓦,Clark Nelson,Jen Maurer,《向量与波前政策》,编程语言 C++ (WG21),P0076r3, http://open-std.org/JTC1/SC22/WG21/docs/papers/2016/p0076r3.pdf
,2016 年 7 月 7 日。
英特尔 64 和 IA-32 架构软件开发人员手册: https://software.intel.com/en-us/articles/intel-sdm
。
英特尔内部函数指南: https://software.intel.com/sites/landingpage/IntrinsicsGuide/
。
开放存取本章根据知识共享署名-非商业-非专用 4.0 国际许可协议(http://Creative Commons . org/licenses/by-NC-nd/4.0/)的条款进行许可,该协议允许以任何媒体或格式进行任何非商业使用、共享、分发和复制,只要您适当注明原作者和来源,提供知识共享许可协议的链接,并指出您是否修改了许可材料。根据本许可证,您无权共享从本章或其部分内容派生的改编材料。
本章中的图像或其他第三方材料包含在本章的知识共享许可中,除非在材料的信用额度中另有说明。如果材料不包括在本章的知识共享许可中,并且您的预期使用不被法律法规允许或超出了允许的使用范围,您将需要直接从版权所有者处获得许可。
五、同步:为什么以及如何避免同步
让我们先强调这一点:如果您不需要使用本章中描述的同步特性,那就更好了。在这里,我们讨论同步机制和实现互斥的替代方案。“同步”和“排除”对于关心性能的并行程序员来说,应该有相当负面的内涵。这些是我们想要避免的操作,因为它们耗费时间,并且在某些情况下,耗费处理器资源和能量。如果我们可以重新思考我们的数据结构和算法,使其既不需要同步也不需要互斥,这是非常好的!不幸的是,在许多情况下,避免同步操作是不可能的,如果这是你今天的情况,请继续阅读!我们从这一章中得到的另外一个信息是,仔细地重新思考我们的算法通常可以得到一个不滥用同步的更干净的实现。我们通过并行化一个简单的代码来说明这种重新思考算法的过程,首先采用一种天真的方法,即求助于互斥体,将其发展为利用原子操作,然后通过私有化和简化技术进一步减少线程之间的同步。在后者中,我们展示了如何利用线程本地存储(TLS)来避免高度竞争的互斥开销。在本章中,我们假设您在某种程度上熟悉“锁”、“共享可变状态”、“互斥”、“线程安全”、“数据竞争”以及其他与同步相关的问题。如果没有,在本书的序言中会有一个温和的介绍。
一个运行的例子:图像的直方图
让我们从一个简单的例子开始,这个例子可以用不同种类的互斥(mutex)对象、原子或者甚至通过完全避免大多数同步操作来实现。我们将描述所有这些可能的实现及其优缺点,并使用它们来说明互斥、锁、原子变量和线程本地存储的使用。
有不同种类的直方图,但图像直方图可能是使用最广泛的,尤其是在图像和视频设备以及图像处理工具中。例如,在几乎所有的照片编辑应用程序中,我们可以很容易地找到一个调色板来显示我们任何一张照片的直方图,如图 5-1 所示。
图 5-1。
灰度图片(Ronda,Málaga)及其相应的图像直方图
为了简单起见,我们将假设灰度图像。在这种情况下,直方图用每个可能的亮度值(x 轴)表示像素数(y 轴)。如果图像像素被表示为字节,那么只有 256 个色调或亮度值是可能的,0 是最暗的色调,255 是最亮的色调。在图 5-1 中,我们可以看到图片中最常见的色调是暗色调:在 5 兆像素中,超过 7 万个具有色调 30,正如我们在 x=30 附近的尖峰处看到的。摄影师和图像专业人员依靠直方图来帮助快速查看像素色调分布,并识别图像信息是否隐藏在图片的任何黑色或饱和区域。
在图 5-2 中,我们展示了 4×4 图像的直方图计算,其中像素只能有从 0 到 7 的八种不同色调。二维图像通常被表示为一维向量,其按照行主顺序存储 16 个像素。因为只有八个不同的音调,所以直方图只需要八个元素,索引从 0 到 7。直方图矢量的元素有时被称为“面元”,我们在其中“分类”,然后对每个色调的像素进行计数。图 5-2 显示了与特定图像相对应的直方图hist
。我们看到存储在一号箱中的“4”是用色调 1 对图像中的四个像素进行计数的结果。因此,在遍历图像时更新面元值的基本操作是hist[<tone>]++
。
图 5-2。
从具有 16 个像素的图像计算直方图 hist(图像的每个值对应于像素色调)
从算法的角度来看,直方图被表示为一个整数数组,其中有足够的元素来说明所有可能的色调级别。假设图像是字节数组,现在有 256 种可能的音调;因此,直方图需要 256 个元素或仓。图 5-3 显示了计算此类图像直方图的顺序代码。
图 5-3。
带有图像直方图计算的顺序实现的代码清单。相关陈述在方框中突出显示。
如果您已经理解了前面代码清单中的所有内容,那么您可能希望跳过本节的其余部分。这段代码首先声明大小为n
的向量image
(比如说一百万个百万像素的图像),在初始化随机数生成器之后,它用类型为uint8_t
的范围【0,255】内的随机数填充图像向量。为此,我们使用一个Mersenne_twister_engine
、mte
,它生成均匀分布在[0, num_bins)
范围内的随机数,并将它们插入到image
向量中。接下来,用num_bins
位置构造hist
向量(默认情况下初始化为零)。注意,我们声明了一个空向量image
,我们后来为它保留了n
整数,而不是构造image(n)
。这样我们就避免了先遍历向量用零初始化,然后再插入随机数。
实际的直方图计算可以使用更传统的方法用 C 编写:
for (int i = 0; i < N; ++i) hist[image[i]]++;
其在直方图矢量的每个仓中计数每个色调值的像素数量。然而,在图 5-3 的例子中,我们想向你展示一个 C++ 的替代方案,它使用 STL for_each
算法,对 C++ 程序员来说可能更自然。使用for_each
STL 方法,图像向量的每个实际元素(类型为uint8_t
的色调)被传递给 lambda 表达式,该表达式增加与色调相关的 bin。为了方便起见,我们依靠tbb::tick_count
类来计算直方图计算中所需的秒数。成员函数now
和seconds
是不言自明的,所以我们在这里不包括进一步的解释。
不安全的并行实现
第一次尝试将直方图计算并行化是使用图 5-4 所示的tbb:
:parallel_for
。
图 5-4。
代码清单用并行实现图像直方图计算
**为了能够比较图 5-3 的顺序执行产生的直方图和并行执行的结果,我们声明一个新的直方图向量hist_p
。接下来,这里疯狂的想法是并行遍历所有像素…为什么不呢?不是独立像素吗?为此,我们依靠第二章中提到的parallel_for
模板,让不同的线程遍历迭代空间的不同块,从而读取图像的不同块。然而,这是行不通的:图 5-4 最后的向量hist
和hist_p
(是的,hist!=hist_p
在 C++ 中做对了),
的比较,揭示了这两个向量是不同的:
c++ -std=c++11 -O2 -o fig_5_4 fig_5_4.cpp -ltbb
./fig_5_4
Serial: 0.606273, Parallel: 6.71982, Speed-up: 0.0902216
Parallel computation failed!!
问题出现了,因为在并行实现中,不同的线程可能同时增加相同的共享 bin。换句话说,我们的代码不是线程安全的(或不安全的)。更正式的说法是,我们的并行不安全代码表现出“未定义的行为”,这也意味着我们的代码是不正确的。在图 5-5 中,我们假设有两个线程 A 和 B 在内核 0 和 1 上运行,每个线程处理一半的像素。由于分配给线程 A 的图像块中有一个亮度为 1 的像素,它将执行hist_p[1]++
。线程 B 也读取一个亮度相同的像素,也会执行hist_p[1]++
。如果两个增量在时间上一致,一个在内核 0 上执行,另一个在内核 1 上执行,那么我们很可能会错过一个增量。
图 5-5。
共享直方图向量的不安全并行更新
这是因为递增操作不是原子的(或不可分的),相反,它通常由三个汇编级操作组成:将变量从内存加载到寄存器,递增寄存器,并将寄存器存储回内存。 1 用一个更正式的行话来说,这种操作被称为读-修改-写或 RMW 操作。对一个共享变量进行并发写入在形式上被称为共享可变状态。在图 5-6 中,我们展示了对应于 C++ 指令hist_p[1]++
的一个可能的机器指令序列。
图 5-6。
共享变量或共享可变状态的不安全更新
如果在执行这两个增量时,我们已经发现一个具有亮度1, hist_p[1]
的先前像素包含值 1。该值可以由两个线程读取并存储在私有寄存器中,这将最终在该 bin 中写入两个而不是三个,这是到目前为止已经遇到的亮度为 1 的像素的正确数量。这个例子在某种程度上过于简单,没有考虑缓存和缓存一致性,但是可以帮助我们说明数据竞争问题。前言中有一个更详细的例子(见图 P-15 和 P-16)。
我们可能会认为这一系列不幸的事件不太可能发生,或者即使发生了,在运行并行版本的算法时,略有不同的结果也是可以接受的。奖励不是更快的执行吗?不完全是这样:正如我们在上一页看到的,我们的不安全并行实现比顺序实现慢 10 倍左右(在四核处理器上运行四个线程,并且n
等于 10 亿像素)。罪魁祸首是前言中介绍的缓存一致性协议(参见前言中的“缓存的局部性和报复”一节)。在串行执行中,直方图向量可能会完全缓存在运行代码的内核的 L1 缓存中。因为有一百万个像素,所以直方图向量中会有一百万个增量,其中大部分以缓存速度提供。
注意
在大多数英特尔处理器上,一条高速缓存线可以容纳 16 个整数(64 字节)。如果向量充分对齐,具有 256 个整数的直方图向量将只需要 16 个高速缓存行。因此,在 16 次缓存未命中之后(或者如果使用预取,则更少),所有直方图仓都被缓存,并且每个仓都仅在大约三个周期内被访问(这是非常快的速度!)在串行实现中(假设有足够大的 L1 高速缓存,并且直方图高速缓存行从不被其他数据驱逐)。
另一方面,在并行实现中,所有线程将争用每个内核私有缓存中的缓存箱,但是当一个线程在一个内核的一个缓存箱中写入时,缓存一致性协议会使适合所有其他内核中相应缓存行的 16 个缓存箱无效。这种无效导致对无效的高速缓存行的后续访问花费比非常期望的 L1 访问时间多一个数量级的时间。这种乒乓相互无效的净效应是并行实现的线程最终增加未缓存的容器,而串行实现的单个线程几乎总是增加缓存的容器。再次记住,一百万像素的图像需要一百万个直方图矢量增量,所以我们希望创建一个尽可能快的增量实现。在直方图计算的这种并行实现中,我们发现了假共享(例如,当线程 A 递增hist_p[0]
而线程 B 递增hist_p[15]
时,因为两个库都在同一高速缓存行中)和真共享(当线程 A 和 B 都递增hist_p[i])
)。我们将在随后的章节中讨论真假共享。
第一个安全的并行实现:粗粒度锁定
让我们首先解决并行访问共享数据结构的问题。我们需要一种机制,当一个不同的线程已经在写入同一个变量时,它可以防止其他线程读取和写入共享变量。用更通俗的话来说,我们想要一个单独的人可以进入的试衣间,看看衣服如何合身,然后离开试衣间,等待下一个排队的人。图 5-7 显示试衣间上的一扇关闭的门排斥其他人。在并行编程中,试衣间的门被称为互斥体,当一个人进入试衣间时,他通过关门和锁门来获取并持有互斥体的锁,当这个人离开时,他们通过让门打开和解锁来释放锁。用更正式的术语来说,互斥体是一个用于在受保护的代码区域的执行中提供互斥的对象。这个需要互斥保护的代码区域通常被称为“关键部分”试衣间的例子也说明了竞争的概念,一种资源(试衣间)同时被多人使用的状态,如图 5-7(c) 所示。由于试衣间一次只能由一个人使用,所以试衣间的使用是“连续的”类似地,受互斥保护的任何东西都会降低程序的性能,首先是因为管理互斥对象带来的额外开销,其次也是更重要的是因为它会引发争用和序列化。我们希望尽可能减少同步的一个关键原因是避免争用和串行化,这反过来限制了并行程序的可伸缩性。
图 5-7。
关上试衣间的门会将其他人拒之门外
在这一节中,我们将重点介绍 TBB 互斥类和相关的同步机制。虽然 TBB 早于 C++11,但值得注意的是 C++11 确实标准化了对互斥类的支持,尽管它不像 TBB 库中的那些那样可定制。在 TBB,最简单的互斥体是在包含了tbb/spin_mutex.h
或包罗万象的tbb.h
头文件之后可以使用的spin_mutex
。有了这个新工具,我们可以实现图像直方图计算的安全并行版本,如图 5-8 所示。
图 5-8。
使用粗粒度锁定的图像直方图计算的第一个安全并行实现的代码清单
在my_mutex
上获得锁的对象my_lock
,当它被创建时,自动解锁(或释放)对象析构函数中的锁,当离开对象范围时调用这个析构函数。因此,建议用额外的大括号{}
将受保护的区域括起来,以保持锁的生存期尽可能短,以便其他等待的线程可以尽快轮到它们。
注意
如果在图 5-8 的代码中,我们忘记给锁对象命名,例如:
// was my_lock{my_mutex} my_mutex_t::scoped_lock {my_mutex};
代码编译时没有警告,但是scoped_lock
的范围在分号处结束。没有对象的名字(my_lock
),我们正在构造一个scoped_lock
类的匿名/未命名对象,它的生命周期在分号处结束,因为没有一个命名对象比定义更长。这是没有用的,临界区是不是互斥保护。
图 5-9 中给出了一个更明确但不推荐的替代方案,即编写图 5-8 的代码。
图 5-9
一种不鼓励的获取互斥锁的方法
C++ 专家们更喜欢图 5-8 中的另一种方案,即所谓的“资源获取即初始化”,RAII,因为它让我们不必记得释放锁。更重要的是,使用 RAII 版本,锁对象析构函数(锁在这里被释放)也会在出现异常的情况下被调用,这样我们就可以避免由于异常而获得锁。如果在图 5-9 的版本中,在调用my_lock.release()
成员函数之前抛出了一个异常,那么无论如何锁也会被释放,因为析构函数被调用,在那里锁被释放。如果一个锁离开了它的作用域,但是之前已经用release()
成员函数释放了,那么析构函数什么也不做。
回到我们的代码图 5-8 ,你可能想知道,“但是等等,我们不是用粗粒度锁序列化了并行代码吗?”是的,你是对的!正如我们在图 5-10 中看到的,每个想要处理其图像块的线程首先试图获取互斥锁,但只有一个会成功,其余的会不耐烦地等待锁被释放。直到持有锁的线程释放它,不同的线程才能执行受保护的代码。因此,parallel_for
最终被串行执行!好消息是,现在直方图柱没有并发增量,结果最终是正确的。耶!
图 5-10
线程 A 持有粗粒度锁以增加库号 1,而线程 B 等待,因为整个直方图向量被锁定
实际上,如果我们编译并运行我们的新版本,我们得到的是比顺序执行稍微慢一点的并行执行:
c++ -std=c++11 -O2 -o fig_5_8 fig_5_8.cpp -ltbb
./fig_5_8
Serial: 0.61068, Parallel: 0.611667, Speed-up: 0.99838
这种方法被称为粗粒度锁定,因为我们保护的是粗粒度数据结构(实际上是整个数据结构——在本例中是直方图向量)。我们可以将向量划分成几个部分,并用它自己的锁来保护每个部分。这样,我们将增加并发级别(访问不同部分的不同线程可以并行进行),但是我们将增加代码的复杂性和每个互斥对象所需的内存。
有一句话要提醒你!图 5-11 展示了并行编程新手的一个常见错误。
图 5-11
并行编程新手常犯的错误
这段代码编译时既没有错误也没有警告,那么它有什么问题呢?回到试衣间的例子,我们的目的是避免几个人同时进入试衣间。前面的代码中,my_mutex
被定义在并行段内部,每个任务会有一个互斥对象,每个都锁定自己的互斥体,并不妨碍对临界段的并发访问。正如我们在图 5-12 中看到的,新手代码本质上为每个人进入同一个试衣间提供了一个单独的门!这不是我们想要的!解决方案是声明my_mutex
一次(就像我们在图 5-8 中所做的那样),这样所有的通道都必须通过同一个门进入试衣间。
图 5-12
有不止一扇门的试衣间
在讨论细粒度锁定替代方案之前,让我们讨论两个值得评论的方面。首先,图 5-8 的“并行化然后串行化”代码的执行时间大于串行实现所需的时间。这是由于“先并行后序列化”的开销,也是由于缓存的利用率较低。当然,没有假共享也没有真共享,因为在我们的序列化实现中根本没有“共享”!还是有?在串行实现中,只有一个线程访问缓存的直方图向量。在粗粒度实现中,当一个线程处理其图像块时,它会将直方图缓存在线程运行的内核的缓存中。当队列中的下一个线程最终可以处理自己的块时,它可能需要在不同的缓存中缓存直方图(如果该线程运行在不同的内核上)。线程仍然共享直方图向量,与串行实现相比,使用建议的实现可能会出现更多的缓存未命中。
我们要提到的第二个方面是通过选择图 5-13 中显示的一种可能的互斥体类型来配置互斥体行为的可能性。因此,建议使用
using my_mutex_t = <mutex_flavor>
或者等效的 C-ish 替代物
typedef <mutex_flavor> my_mutex_t;
然后使用my_mutex_t
前进。这样,我们可以很容易地在一个程序行中改变互斥体的风格,并通过实验很容易地评估哪种风格最适合我们。可能需要包含不同的头文件,如图 5-13 所示,或者使用全包tbb.h
.
图 5-13
不同的互斥风格及其属性
互斥口味
为了理解互斥体的不同风格,我们必须首先描述我们用来对它们进行分类的属性:
-
可扩展互斥体在等待时不会消耗过多的内核周期和内存带宽。动机是等待线程应该避免消耗其他非等待线程可能需要的硬件资源。
-
公平的互斥体使用 FIFO 策略让线程轮流执行。
-
递归互斥锁允许已经持有一个互斥锁的线程可以获得同一个互斥锁的另一个锁。重新思考您的代码以避免互斥是很好的,这样做以避免递归互斥几乎是必须的!那么,TBB 为什么提供它们呢?在某些情况下,递归互斥是不可避免的。当我们不想被打扰或没有时间重新思考更有效的解决方案时,它们也可能会派上用场。
在图 5-13 的表格中,我们还包括了互斥对象的大小和线程的行为,如果它必须等待很长时间才能锁定互斥对象的话。关于最后一点,当一个线程正在等待轮到它的时候,它可以忙-等待、阻塞或放弃。阻塞的线程将被更改为阻塞状态,这样线程所需的唯一资源就是保持其睡眠状态的内存。当线程最终获得锁时,它会醒来并返回到就绪状态,此时所有就绪的线程都在等待下一轮。OS 调度程序将时间片分配给在就绪状态队列中等待的就绪线程。在等待轮到它持有锁时让步的线程被保持在就绪状态。当线程到达就绪状态队列的顶部时,它被分派运行,但是如果互斥体仍然被其他线程锁定,它再次释放它的时间片(它没有其他事情可做!)并返回就绪状态队列。
注意
请注意,在此过程中可能涉及两个队列:(I)由操作系统调度程序管理的就绪状态队列,其中就绪线程正在等待(不一定按 FIFO 顺序)被分派到空闲内核并成为运行线程,以及(ii)由操作系统或用户空间中的互斥体库管理的互斥体队列,其中线程等待轮到它们获取排队互斥体上的锁。
如果内核没有被超额订阅(在这个内核中只有一个线程在运行),由于互斥体仍然被锁定而退出的线程将是就绪状态队列中唯一的线程,并被立即调度。在这种情况下,让步机制实际上相当于忙等待。
既然我们已经理解了可以表征互斥体实现的不同属性,让我们深入研究 TBB 提供的特定互斥体风格。
mutex
和recursive_mutex
是围绕操作系统提供的互斥机制的 TBB 包装器。我们不使用“本地”互斥体,而是使用 TBB 包装器,因为它们为其他 TBB 互斥体添加了异常安全和相同的接口。这些互斥锁会阻塞长时间等待,因此它们浪费的周期较少,但是当互斥锁可用时,它们会占用更多的空间,并且具有更长的响应时间。
spin_mutex
相反,从不屏蔽。它在用户空间中旋转 busy-waiting,同时等待持有互斥锁。等待线程将在多次尝试获取循环后放弃,但如果内核没有超额预订,该线程将继续浪费内核的周期和功率。另一方面,一旦互斥体被释放,获取它的响应时间是最快的(不需要醒来并等待被调度运行)。这个互斥锁是不公平的,所以不管一个线程已经等待了多长时间,如果一个更快的线程第一个发现互斥锁被解锁,它就可以超过它并获得锁。在这种情况下,自由竞争占了上风,在极端情况下,弱线程可能会饿死,永远得不到锁。尽管如此,在轻度争用的情况下,这是推荐的互斥风格,因为它可能是最快的。
queueing_mutex
是spin_mutex
的可扩展和公平版本。它仍然在旋转,在用户空间中忙着等待,但是等待互斥体的线程将按照 FIFO 的顺序获得锁,所以饥饿是不可能的。
speculative_spin_mutex
构建在某些处理器中可用的硬件事务内存(HTM)之上。HTM 的哲学是乐观!HTM 让所有线程同时进入临界区,希望不会有共享内存冲突!但是如果有呢?在这种情况下,硬件检测到冲突并回滚其中一个冲突线程的执行,该线程必须重试临界区的执行。在图 5-8 所示的粗粒度实现中,我们可以添加下面这行代码:
using my_mutex_t = speculative_spin_mutex;
然后,穿过图像的parallel_for
再次变得并行。现在,所有线程都被允许进入临界区(为图像的给定块更新直方图的仓),但只有在更新其中一个仓时存在实际冲突的情况下,其中一个冲突线程才必须重试执行。为了有效地工作,受保护的临界区必须足够小,以使冲突和重试很少发生,这与图 5-8 中的代码不同。
spin_rw_mutex
,
queueing_rw_mutex
,
和speculative_spin_rw_mutex
是前面提到的各种风味的读者-作者互斥对应。这些实现允许多个读取器同时读取一个共享变量。锁对象构造器有第二个参数,一个布尔值,如果我们只在临界区内读(不写),我们将它设置为 false:
如果出于某种原因,必须将一个读线程锁升级为写线程锁,TBB 提供了一个upgrade_to_writer()
成员函数,可以按如下方式使用:
如果my_lock
在没有释放锁的情况下成功升级为写线程锁,则返回 true,否则返回 false。
最后,我们有null_mutex
和null_rw_mutex
,它们只是不做任何事情的虚拟对象。那么,有什么意义呢?如果我们将一个互斥对象传递给一个可能需要也可能不需要真正互斥体的函数模板,我们会发现这些互斥体很有用。如果函数并不真的需要互斥体,只需传递伪类型即可。
第二个安全的并行实现:细粒度锁定
既然我们已经对不同种类的互斥体有了很多了解,让我们考虑一下图 5-8 中粗粒度锁的另一种实现。一种替代方法是为直方图的每个库声明一个互斥体,这样我们就不用用一个锁来锁定整个数据结构,而是只保护我们实际上正在增加的单个内存位置。为此,我们需要一个互斥体的载体fine_m
,如图 5-14 所示。
图 5-14
使用细粒度锁定的图像直方图计算的第二个安全并行实现的代码清单
正如我们在parallel_for
中使用的 lambda 中看到的,当一个线程需要增加容器hist_p[tone]
时,它将获得fine_m[tone]
上的锁,防止其他线程接触同一个容器。基本上“你可以更新其他的媒体夹,但不能更新这个特定的媒体夹。”这如图 5-15 所示,其中线程 A 和线程 B 并行更新直方图向量的不同仓。
图 5-15
由于细粒度的锁定,我们可以利用更多的并行性
然而,从性能的角度来看,这种替代方案并不是真正的最佳方案(实际上,它是迄今为止最慢的替代方案):
c++ -std=c++11 -O2 -o fig_5_14 fig_5_14.cpp -ltbb
./fig_5_14
Serial: 0.59297, Parallel: 26.9251, Speed-up: 0.0220229
现在我们不仅需要直方图数组,还需要相同长度的互斥对象数组。这意味着更大的内存需求,此外,更多的数据将被缓存,并将遭受假共享和真共享。倒霉!
除了锁固有的开销之外,锁还是另外两个问题的根源:护送和死锁。让我们先来看看“护送”这个名字来自于所有线程以第一个线程的较低速度一个接一个地护航的心理图像。我们需要一个例子来更好地说明这种情况,如图 5-16 所示。假设我们有线程 1、2、3 和 4 在同一个内核上执行相同的代码,其中有一个关键部分受到自旋互斥体 a 的保护,如果这些线程在不同的时间持有锁,它们会愉快地运行而不会发生争用(情况 1)。但是可能发生的情况是,线程 1 在释放锁之前用完了它的时间片,这将发送一个到就绪状态队列的末尾(情况 2)。
图 5-16
超额订阅情况下的护送(一个内核运行四个线程,所有线程都需要相同的互斥 A)
线程 2、3 和 4 现在将获得它们对应的时间片,但是它们不能获得锁,因为 1 仍然是所有者(情况 3)。这意味着 2,3,4 现在可以让行或旋转,但无论如何,他们都卡在一档大卡车后面。当再次调度 1 时,它将释放锁 A(情况 4)。现在 2 号、3 号和 4 号都准备好争夺锁了,只有一个成功了,其他的都在等待。这种情况经常发生,尤其是如果线程 2、3 和 4 需要更多的时间片来运行它们受保护的临界区。此外,线程 2、3 和 4 现在被不经意地协调了,它们都在代码的同一个区域运行,这导致了互斥体上更高的争用概率!请注意,当内核超额预订时(如本例所示,四个线程竞争运行在一个内核上),护送尤为严重,这也强化了我们避免超额预订的建议。
锁带来的另一个众所周知的问题是“死锁”图 5-17(a) 显示了一个噩梦般的场景,在这个场景中,即使有可用的资源(没有车可以使用的空行),也没有人能够取得进展。这是现实生活中的僵局,但是把这种形象从你的头脑中去掉(如果你可以的话!)并回到我们的并行编程虚拟世界。如果我们有一组 N 个线程,它们持有一个锁,并且还在等待获取该组中任何其他线程已经持有的锁,那么我们的 N 个线程就被死锁了。图 5-17(b) 给出了一个只有两个线程的例子:线程 1 持有互斥体 A 的锁,并等待获取互斥体 B 的锁,但是线程 2 已经持有互斥体 B 的锁,并等待获取互斥体 A 的锁。很明显,没有线程会继续前进,永远注定在一个致命的拥抱中!如果线程已经拥有一个互斥体,我们可以通过不要求获取不同的互斥体来避免这种不幸的情况。或者至少让所有线程总是以相同的顺序获取锁。
图 5-17
死锁情况
如果一个已经持有锁的线程调用了一个也获得了不同锁的函数,我们可能会无意中引发死锁。如果我们不知道函数做什么,建议避免在持有锁的情况下调用函数(通常建议不要在持有锁的情况下调用其他人的代码)。或者,我们应该仔细检查后续函数调用链不会导致死锁。啊!我们也可以尽可能避免锁!
虽然护送和死锁并没有真正影响我们的直方图实现,但它们应该有助于让我们相信锁带来的问题往往比它们解决的问题更多,并且它们不是获得高并行性能的最佳选择。只有当争用的可能性很低并且执行临界区的时间很短时,锁才是可以容忍的选择。在这些情况下,一个基本的spin_lock
或speculative_spin_lock
可以产生一些加速。但是在任何其他情况下,lock based
算法的可伸缩性都会受到严重损害,最好的建议是跳出框框,设计一个完全不需要互斥的新实现。但是,我们能否在不依赖于几个互斥对象的情况下获得细粒度的同步,从而避免相应的开销和潜在问题呢?
第三种安全的并行实现:原子
幸运的是,在许多情况下,我们可以借助一种更便宜的机制来摆脱互斥锁和锁。我们可以使用原子变量来执行原子操作。如图 5-6 所示,递增操作不是原子操作,而是可以分成三个更小的操作(加载、递增和存储)。但是,如果我们声明一个原子变量并执行以下操作:
原子变量的增量是原子操作。这意味着任何其他访问 counter 值的线程都将“看到”该操作,就好像递增是在一个单独的步骤中完成的一样(不是三个较小的操作,而是一个单独的步骤)。也就是说,任何其他“眼尖”的线程要么观察到操作完成,要么观察不到,但它永远不会观察到增量完成一半。
原子操作不会遭受护送或死锁 2 并且比互斥选择更快。然而,并不是所有的操作都可以自动执行,那些可以自动执行的操作也不适用于所有的数据类型。更准确地说,当T
是整数、枚举或指针数据类型时,atomic<T>
支持原子操作。图 5-18 中列出了此类atomic<T>
变量x
支持的原子操作。
图 5-18
原子变量的基本运算
通过这五个操作,可以实现大量的派生操作。比如x++
、x--
、x+=...
、x-=...
都来源于x.fetch_and_add()
。
注意
正如我们在前面的章节中已经提到的,从 C++11 开始,C++ 也包含了线程和同步特性。在这些特性被 C++ 标准接受之前,TBB 就包含了它们。尽管从 C++11 开始,std::mutex
和std::atomic
以及其他的都可用,TBB 仍然在它的tbb::mutex
和tbb::atomic classes
中提供了一些重叠的功能,主要是为了与以前开发的基于 TBB 的应用程序兼容。我们可以在同一个代码中毫无问题地使用这两种风格,并且由我们来决定在给定的情况下哪一种更好。关于std::atomic
,一些额外的性能,w.r.t. tbb::atomic
,如果用于在“弱有序”架构上开发无锁算法和数据结构(如 ARM 或 PowerPC 相比之下,英特尔 CPU 具有强有序内存模型)。在本章的最后一节“更多信息”中,我们推荐进一步阅读与内存一致性和 C++ 并发模型相关的内容,在这些内容中,这个主题得到了充分的阐述。对于我们这里的目的,可以说fetch_and_store
、fetch_and_add
和compare_and_swap
默认遵循顺序一致性(C++ 术语中的memory_order_seq_cst
),这可以防止一些无序的执行,因此花费了少量的额外时间。考虑到这一点,TBB 还提供了释放和获取语义:在原子读取中默认获取(...=x
);并在原子写中默认释放(x=...
)。所需的语义也可以使用模板参数来指定,例如,x.fetch_and_add<release>
只强制执行释放内存顺序。在 C++11 中,还允许其他更宽松的内存顺序(memory_order_relaxed 和 memory_order_consume ),在特定的情况下和架构中,可以允许读写顺序有更大的自由度,并挤压少量的额外性能。如果我们想要更接近金属以获得最终的性能,即使知道额外的编码和调试负担,那么 C++11 较低级别的特性就在那里,然而我们可以将它们与 TBB 提供的较高级别的抽象相结合。
另一个基于原子的有用的习惯用法是已经在图 2-23 (第二章)给出的波前例子中使用的。将原子整数refCount
初始化为“y ”,几个线程执行这段代码:
将导致只有第 y 个线程执行进入“body”的前一行。
在这五个基本操作中,compare_and_swap
(CAS)可以被认为是所有原子读-修改-写,RMW 操作之母。这是因为所有原子 RMW 操作都可以在 CAS 操作之上实现。
注意
万一您需要保护一个小的临界区,并且您已经确信无论如何都要避开锁,那么让我们稍微研究一下 CAS 操作的细节。假设我们的代码需要将一个共享的整数变量v
乘以 3(不要问我们为什么!我们有我们的理由!).我们的目标是一个无锁的解决方案,尽管我们知道乘法不包括在原子操作中。这就是 CAS 的用武之地。第一件事是将v
声明为原子变量:
tbb::atomic<uint_32_t> v;
所以现在我们可以调用v.compare_and_swap(new_v, old_v)
,而在原子层面上调用
这是,当且仅当v
等于old_v
时,我们可以用新值更新v
。无论如何,我们返回ov
(在“==”比较中使用的共享v
)。现在,实现我们的“乘以 3”原子乘法的技巧是编写被称为 CAS 循环的代码:
我们的新fetch_and_triple
是线程安全的(可以被几个线程同时安全地调用),即使它被调用时传递相同的共享原子变量。这个函数基本上是一个 do-while 循环,在这个循环中,我们首先拍摄共享变量的快照(如果其他线程已经设法修改了它,这是稍后进行比较的关键)。然后,原子地,如果没有其他线程改变了 v
(v==old_v)
,我们就更新它(v=old_v*3)
并返回v
。因为在这种情况下v == old_v
(同样:没有其他线程改变v
),我们离开 do-while 循环并从函数返回,共享的v
成功更新。
不过拍快照之后,有可能其他线程更新 v
。在这种情况下,v!=old_v
,这意味着(I)我们不更新v
,以及(ii)我们停留在 do-while 循环中,希望幸运女神下次会对我们微笑(当在我们拍摄快照和我们成功更新v
之间的过渡期间,没有其他贪婪的线程敢碰我们的v
。图 5-19 说明了线程 1 或线程 2 如何更新 v。有可能其中一个线程不得不重试一次或多次(例如线程 2 在最初准备写 27 时却写了 81 ),但是在设计良好的场景中这应该没什么大不了。
这种策略的两个警告是(I)它的伸缩性很差,以及(ii)它可能遭受“ABA 问题”(在第 201 页的第六章中有关于经典 ABA 问题的背景)。关于第一个,考虑竞争相同原子的 P 个线程,只有一个线程成功进行 P-1 次重试,然后另一个线程成功进行 P-2 次重试,然后 P-3 次重试,等等,导致二次工作。这个问题可以借助于“指数后退”策略来改善,该策略成倍地降低连续重试的速率以减少争用。另一方面,当在中间时间(在我们拍摄快照的时刻和我们成功更新v)
的时刻之间),一个不同的线程将v
从值A
改变为值B
并变回值A
时,就会发生 ABA 问题。我们的 CAS 循环可以在没有注意到中间线程的情况下成功,这可能是有问题的。如果您在开发中需要求助于 CAS 循环,请仔细检查您是否理解这个问题及其后果。
图 5-19
两个线程同时调用我们在 CAS 循环上实现的fetch_and_triple
原子函数
但是现在是时候回到我们运行的例子了。直方图计算的重新实现现在可以借助原子来表达,如图 5-20 所示。
图 5-20
使用原子变量的图像直方图计算的第三种安全并行实现的代码清单
在这个实现中,我们去掉了互斥对象和锁,并声明了向量,使得每个 bin 都是一个tbb::atomic<int>
(默认情况下初始化为0
)。然后,在 lambda 中,并行增加容器是安全的。最终结果是,我们获得了直方图向量的并行增量,就像细粒度锁定策略一样,但是在互斥体管理和互斥体存储方面的成本更低。
然而,就性能而言,以前的实现仍然太慢:
c++ -std=c++11 -O2 -o fig_5_20 fig_5_20.cpp -ltbb
./fig_5_20
Serial: 0.614786, Parallel: 7.90455, Speed-up: 0.0710006
除了原子增量开销之外,伪共享和真共享是我们还没有解决的问题。在第七章中,通过利用对齐的分配器和填充技术来解决假共享问题。错误共享是妨碍并行性能的常见障碍,所以我们强烈建议您阅读第七章中推荐的避免错误共享的技术。
太好了,假设我们已经修复了假分享的问题,那么真分享的呢?两个不同的线程最终将增加同一个容器,这将从一个高速缓存乒乓到另一个。我们需要一个更好的主意来解决这个问题!
更好的并行实施:私有化和削减
直方图缩减带来的真正问题是,只有一个共享向量来保存所有线程都渴望增加的 256 个容器。到目前为止,我们已经看到了几种功能相当的实现,比如粗粒度的、细粒度的和基于原子的实现,但是如果我们还考虑性能和能量等非功能性指标,这些实现都不完全令人满意。
避免共享的常见解决方案是将其私有化。并行编程在这方面没有什么不同。如果我们给每个线程一个直方图的私有副本,每个线程都会愉快地使用它的副本,将其缓存在线程正在运行的内核的私有缓存中,因此以缓存速度递增所有的容器(在理想情况下)。不再有假共享,也没有真共享,也没有什么,因为直方图矢量不再被共享。
好吧,但是…每个线程最终都会看到直方图的一部分,因为每个线程只访问了整个图像的一些像素。没问题,现在是这个实现的缩减部分发挥作用的时候了。计算直方图的私有部分版本后的最后一步是减少所有线程的所有贡献,以获得完整的直方图向量。这部分仍然有一些同步,因为一些线程必须等待其他尚未完成本地/私有计算的线程,但是在一般情况下,这种解决方案最终比前面描述的其他实现要便宜得多。图 5-21 展示了直方图示例的私有化和缩减技术。
图 5-21
每个线程计算其局部直方图my_hist
,该直方图稍后在第二步中被减少。
TBB 提供了几个备选方案来完成私有化和归约操作,一些基于线程本地存储(TLS ),另一个基于归约模板,更加用户友好。让我们先来看看 TLS 版本的直方图计算。
线程本地存储
在这里,线程本地存储指的是每个线程都有一个私有的数据副本。使用 TLS,我们可以减少对线程间共享可变状态的访问,还可以利用局部性,因为每个私有副本可以(有时是部分地)存储在线程运行的内核的本地缓存中。当然,副本会占用空间,所以不能过度使用。
TBB 的一个重要方面是我们不知道在任何给定的时间有多少线程正在被使用。即使我们运行在 32 核系统上,并且我们使用parallel_for
进行 32 次迭代,我们也不能假设会有 32 个线程处于活动状态。这是使我们的代码可组合的一个关键因素,这意味着即使它在一个并行程序中被调用,或者如果它调用一个并行运行的库,它也将工作(更多细节见第九章)。因此,我们不知道需要多少数据的线程本地副本,即使在我们的 32 次迭代的parallel_for
的例子中。TBB 线程本地存储的模板类在这里给出了一个抽象的方法,让 TBB 分配、操作和组合正确数量的副本,而不用我们担心有多少副本。这让我们能够创建可伸缩、可组合和可移植的应用程序。
TBB 为线程本地存储提供了两个模板类。两者都为每个线程提供对本地元素的访问,并按需创建元素(延迟)。它们的预期使用模式不同:
-
ETS 类提供了线程本地存储,就像一个 STL 容器,每个线程一个元素。容器允许使用通常的 STL 迭代习惯来迭代元素。任何线程都可以遍历所有本地副本,看到其他线程的本地数据。
-
类
combinable
提供线程本地存储,用于保存每个线程的子计算,这些子计算稍后将被简化为单个结果。每个线程只能看到它的本地数据,或者在调用 combine 后,只能看到合并后的数据。
可枚举线程特定
首先,让我们看看如何通过enumerable_thread_specific
类实现我们的并行直方图计算。在图 5-22 中,我们看到并行处理输入图像的不同块并让每个线程写入直方图向量的私有副本所需的代码。
图 5-22
使用类enumerable_thread_specific
对私有副本进行并行直方图计算
我们首先声明一个类型为vector<int>.
的enumerable_thread_specific
对象priv_h
,构造器指出向量大小为num_bins
整数。然后,在parallel_for,
内部,不确定数量的线程将处理迭代空间的块,对于每个块,将执行parallel_for
的主体(在我们的例子中是 lambda)。负责给定块的线程调用my_hist = priv_h.local()
,其工作方式如下。如果这是这个线程第一次调用local()
成员函数,就会为这个线程创建一个新的私有向量。如果相反,它不是第一次,向量已经被创建,我们只需要重用它。在这两种情况下,对私有向量的引用被返回并分配给my_hist,
,它在parallel_for
中被用来更新给定块的直方图计数。这样,处理不同块的线程将为第一个块创建私有直方图,并在后续块中重用它。很整洁,对吧?
在parallel_for
结束时,我们以未确定数量的私有直方图结束,这些直方图需要被组合以计算最终直方图hist_p
,累积所有的部分结果。但是,如果我们甚至不知道私有直方图的数量,我们如何进行这种简化呢?幸运的是,enumerable_thread_specific
不仅为T
类型的元素提供线程本地存储,还可以像 STL 容器一样从头到尾迭代。这在图 5-22 的末尾执行,其中变量i
(类型priv_h_t::const_iterator
)顺序遍历不同的私有直方图,嵌套循环j
负责在hist_p
上累积所有的箱计数。
如果我们更想炫耀我们出色的 C++ 编程技能,我们可以利用priv_h
是另一个 STL 容器的事实,编写如图 5-23 所示的简化。
图 5-23
实现缩减的更时尚的方式
由于归约操作非常频繁,enumerable_thread_specific
还提供了两个额外的成员函数来实现归约:图 5-24 中的combine_each()
和combine().
,我们在一个完全等同于图 5-23 的代码片段中演示了如何使用成员函数combine_each
。
图 5-24
使用combine_each()
实现还原
成员函数combine_each()
有这样的原型:
正如我们在图 5-24 中看到的,Func f
作为一个 lambda 提供,其中 STL transform
算法负责将私有直方图累积到hist_p
中。通常,成员函数combine_each
为enumerate_thread_specific
对象中的每个元素调用一元仿函数。这个带有签名void(T)
或void(const T&)
的组合函数通常将私有副本减少到一个全局变量中。
备选成员函数combine()
确实返回类型为T
的值,并且具有以下原型:
在图 5-25 中,二元函子f
应该具有签名T(T,T)
或T(const T&,const T&).
,我们显示了使用T(T,T)
签名的归约实现,对于每对私有向量,计算向量与向量a
的加法,并将其返回以进行可能的进一步归约。combine()
成员函数负责访问直方图的所有本地副本,以返回一个指向最终hist_p.
的指针
图 5-25
使用combine()
实现相同的缩减
而并行性能呢?
c++ -std=c++11 -O2 -o fig_5_22 fig_5_22.cpp -ltbb
./fig_5_22
Serial: 0.668987, Parallel: 0.164948, Speed-up: 4.05574
现在我们正在谈话!请记住,我们在四核机器上运行这些实验,因此 4.05 的加速比实际上有点超线性(由于四核的 L1 缓存的聚合)。图 5-23 、 5-24 和 5-25 中所示的三个等效缩减是顺序执行的,因此如果要缩减的私有副本的数量很大(比如说 64 个线程正在计算直方图)或者缩减操作是计算密集型的(例如,私有直方图有 1024 个仓),则仍有性能改进的空间。我们也将解决这个问题,但首先我们想讨论实现线程本地存储的第二种选择。
可组合的
一个combinable<T>
对象为每个线程提供了自己的本地实例,类型为T,
,用于在并行计算期间保存线程本地值。与之前描述的 ETS 类相反,一个可组合的对象不能像我们在图 5-22 和 5-23 中使用priv_h
那样被迭代。然而,combine_each()
和combine()
成员函数是可用的,因为这个combinable
类是在 TBB 提供的,其唯一目的是实现本地数据存储的减少。
在图 5-26 中,我们再次重新实现了并行直方图计算,现在依赖于可组合的类。
图 5-26
用combinable
对象重新实现直方图计算
在这种情况下,priv_h
是一个可组合的对象,其中构造器提供了一个 lambda,该函数将在每次调用priv_h.local()
时被调用。在这种情况下,这个 lambda 只是创建了一个num_bins
整数的空向量。更新每线程私有直方图的parallel_for
与图 5-22 所示的 ETS 替代方案的实现非常相似,除了my_hist
只是对整数向量的引用。正如我们所说,现在我们不能像在图 5-22 中那样手工迭代私有直方图,但是为了弥补这一点,成员函数combine_each()
和combine()
的工作方式与我们在图 5-24 和 5-25 中看到的 ETS 类的等价成员函数非常相似。请注意,这种缩减仍然是按顺序执行的,因此仅当要缩减的对象数量和/或缩减两个对象的时间很小时才适用。
ETS 和可组合类具有附加的成员函数和高级用途,详见附录 b。
最简单的并行实现:归约模板
正如我们在第二章中提到的,TBB 已经有了一个高级并行算法来轻松实现一个parallel_reduce
。那么,如果我们要实现私有直方图的并行归约,为什么不仅仅依靠这个parallel_reduce
模板呢?在图 5-27 中,我们看到了如何使用这个模板来编码一个高效的并行直方图计算。
图 5-27
使用私有化和简化的图像直方图计算的更好的并行实现的代码清单
parallel_reduce
的第一个参数是迭代的范围,它将被自动划分成块并分配给线程。有些过分简化了实际发生的事情,线程将获得一个用归约操作的标识值初始化的私有直方图,在本例中是初始化为 0 的面元向量。第一个 lambda 负责局部直方图的私有和本地计算,该局部直方图是通过仅访问图像的一些块而产生的。最后,第二个 lambda 实现了归约操作,在这种情况下可以表示为
这正是std:
:transform
STL 算法正在做的事情。执行时间与使用 ETS 获得的时间相似,并且可以组合:
c++ -std=c++11 -O2 -o fig_5_27 fig_5_27.cpp -ltbb
./fig_5_27
Serial: 0.594347, Parallel: 0.148108, Speed-up: 4.01293
为了更清楚地说明我们迄今为止讨论的直方图的不同实现的实际含义,我们在图 5-28 中收集了在我们的四核处理器上获得的所有加速。更准确地说,处理器是 2.6 GHz 的酷睿 i7-6700HQ (Skylake 架构,第六代),6 MB 三级高速缓存和 16 GB RAM。
图 5-28
英特尔酷睿 i7-6700HQ (Skylake)上不同直方图实施的加速
我们清楚地识别出三种不同的行为。四核的不安全、细粒度锁定和原子解决方案比顺序解决方案慢得多(慢得多意味着慢了不止一个数量级!).正如我们所说的,由于锁和假共享/真共享而导致的频繁同步是一个真正的问题,直方图仓在一个缓存和另一个缓存之间来回移动会导致非常令人失望的加速。细粒度的解决方案是最差的,因为直方图向量和互斥向量都有假共享和真共享。作为同类解决方案中的一个代表,粗粒度解决方案只是比顺序解决方案稍差一些。请记住,这只是一个“并行化然后序列化”的版本,其中粗粒度锁迫使线程一个接一个地进入临界区。粗粒度版本的小性能下降实际上是测量并行化和互斥管理的开销,但是我们现在没有假共享或真共享。最后,私有化+减支解决方案(TLS 和parallel_reduction
)领先群雄。它们的伸缩性很好,甚至比线性更好,因为parallel_reduction
由于树状缩减而有点慢,在这个问题上没有回报。核的数量很少,减少所需的时间(增加到 256 个int
向量)可以忽略不计。对于这个小问题,用 TLS 类实现的顺序缩减已经足够好了。
概述我们的选择
为了支持我们提出的所有不同的备选方案,实现一个简单的算法,如直方图计算算法,让我们回顾并详细说明每个备选方案的优缺点。图 5-29 展示了我们的一些选项,使用八个线程进行 800 个数字的简单矢量加法。相应的顺序代码将类似于
正如在《善、恶、丑》中一样,这一章的“角色”是“错误的、顽强的、桂冠的、核心的、本地的和明智的”:
图 5-29
用八个线程对 800 个数求和时避免争用:(A)原子的:用原子操作 s 保护全局和,(B)局部的:使用enumerable_thread_specific
,(C)明智的:使用parallel_reduce
。
-
错误的:我们可以让八个线程并行递增一个全局计数器
sum_g
,而无需任何进一步的考虑、思考或悔恨!最有可能的是,sum_g
最终会不正确,缓存一致性协议也会破坏性能。你已经被警告了。 -
Hardy:如果我们使用粗粒度锁定,我们会得到正确的结果,但通常我们也会序列化代码,除非互斥体实现了 HTM(就像投机风格那样)。这是保护临界区最简单的方法,但不是最有效的方法。对于我们的 vector sum 示例,我们将通过保护每个 vector chunk 累积来说明粗粒度锁定,从而获得一个粗粒度临界区。
-
Laurel:细粒度锁实现起来更费力,通常需要更多内存来存储保护数据结构细粒度部分的不同互斥体。不过,令人欣慰的是线程间的并发性增加了。我们可能想要评估不同的互斥体风格,以便在产品代码中选择最好的一个。对于矢量和,我们没有一个可以分区的数据结构,这样每个部分都可以被独立保护。让我们考虑一个细粒度的实现,在下面的实现中,我们有一个较轻的临界区(在这种情况下,与粗粒度的实现一样串行,但是线程在更细粒度上竞争锁)。
-
在某些情况下,原子变量可以帮助我们。例如,当共享的可变状态可以存储在整型中,并且所需的操作足够简单时。这比细粒度的锁定方法成本更低,并发级别也不相上下。向量和示例(见图 5-29(A) )如下,在这种情况下,与前两种方法一样连续,并且全局变量与细粒度情况下一样高度竞争。
-
本地:我们并不总是能够提出一个实现,将共享可变状态的本地副本私有化来扭转局面。但在这种情况下,线程本地存储 TLS 可以通过
enumerate_thread_specific
、ETS 或combinable
类来实现。即使协作线程的数量未知,并且提供了方便的减少方法,它们也能工作。这些类提供了足够的灵活性,可以在不同的场景中使用,并且当单个迭代空间的缩减不够时,可以满足我们的需要。为了计算矢量和,我们在下面给出一个替代方案,其中私有部分和priv_s
随后被顺序累加,如图 5-29(B) 所示。 -
明智之举:当我们的计算符合归约模式时,强烈建议依赖
parallel_reduction
模板,而不是使用 TBB 线程本地存储特性手工编码私有化和归约。下面的代码可能看起来比前一个更复杂,但是聪明的软件架构师设计了巧妙的技巧来完全优化这个常见的归约操作。例如,在这种情况下,归约操作遵循一种类似树的方法,复杂性为O(log n)
而不是O(n)
,如图 5-29(C) 所示。利用图书馆放在你手中的东西,而不是重新发明轮子。这无疑是最适合大量内核和成本高昂的缩减操作的方法。
与直方图计算一样,我们还在我们的酷睿 i7 四核架构上评估了大小为 10 9 的矢量加法的不同实现的性能,如图 5-30 所示。现在计算更加精细了(只是增加了一个变量),10 个 9 个锁定-解锁操作或原子增量的相对影响更大了,这可以从加速中看出(更确切地说是减速!)的原子(核)和细粒度(劳雷尔)实现。粗粒度(Hardy)实现现在比直方图情况下受到的冲击稍大。TLS(本地)方法仅比顺序代码快 1.86 倍。unsafe(missed)现在比 sequential 快 3.37 倍,现在的赢家是parallel_reduction
(Wise)实现,它为四个内核提供了 3.53 倍的加速。
图 5-30
在英特尔酷睿 i7-6700HQ (Skylake)上,针对 N=10 9 的向量加法的不同实现的加速
你可能想知道为什么我们经历了所有这些不同的选择,最终推荐了最后一个。如果parallel_reduce
解决方案是最佳方案,我们为什么不直接采用它呢?不幸的是,并行生活是艰难的,并不是所有的并行化问题都可以通过简单的简化来解决。在这一章中,我们将为您提供利用同步机制的设备,如果它们真的是必要的话,同时也展示了重新思考算法和数据结构的好处。
摘要
当我们需要安全地访问共享数据时,TBB 库提供了不同风格的互斥体和原子变量来帮助我们同步线程。该库还提供线程本地存储、TLS、类(如 ETS 和combinable
)和算法(如parallel_reduction
),帮助我们避免同步的需要。在本章中,我们经历了并行图像直方图计算的史诗般的旅程。对于这个正在运行的示例,我们看到不同的并行实现从一个不正确的实现开始,然后遍历不同的同步备选方案,如粗粒度锁定、细粒度锁定和原子,最后得到一些根本不使用锁的备选实现。在途中,我们在一些值得注意的地方停了下来,介绍了允许我们描述互斥体的特性、TBB 库中可用的不同种类的互斥体,以及依赖互斥体实现我们的算法时通常会出现的常见问题。现在,在旅程的最后,这一章带给我们的启示是显而易见的:除非性能不是您的目标,否则不要使用锁!
更多信息
以下是我们推荐的一些与本章相关的额外阅读材料:
-
C++ 并发在行动,安东尼·威廉姆斯,曼宁出版社,第二版,2018 年。
-
《内存一致性和缓存一致性入门》, Daniel J. Sorin、Mark D. Hill 和 David A. Wood,Morgan & Claypool 出版社,2011 年。
马拉加朗达的照片,如图 5-1 ,作者拉斐尔·阿森约拍摄,经许可使用。
第五章中显示的迷因数字是经 365psd.com 许可使用的“33 个矢量迷因面”
图 5-17 中的交通堵塞由 Denisa-Adreea Constantinescu 在马拉加大学攻读博士学位时绘制,经允许使用。
开放存取本章根据知识共享署名-非商业-非专用 4.0 国际许可协议(http://Creative Commons . org/licenses/by-NC-nd/4.0/)的条款进行许可,该协议允许以任何媒体或格式进行任何非商业使用、共享、分发和复制,只要您适当注明原作者和来源,提供知识共享许可协议的链接,并指出您是否修改了许可材料。根据本许可证,您无权共享从本章或其部分内容派生的改编材料。
本章中的图像或其他第三方材料包含在该章的知识共享许可中,除非该材料的信用额度中另有说明。如果材料未包含在本章的知识共享许可中,并且您的预期用途不被法定法规允许或超出了允许的用途,您将需要直接从版权所有者处获得许可。
**六、并发的数据结构
在前一章,我们分享了我们有多不喜欢锁。我们不喜欢它们,因为它们会限制规模,降低并行程序的效率。当然,当正确需要时,它们可以是“必要的邪恶”;然而,我们被很好地建议构造我们的算法以最小化对锁的需求。这一章给了我们一些有用的工具。第章第一章–第四章关注可扩展算法。一个共同的特征是它们避免或最小化了锁定。第五章介绍了显式的同步方法,包括在我们需要的时候使用锁。在接下来的两章中,我们提供了通过依赖 TBB 的特性来避免使用显式同步的方法。在这一章中,我们将带着避免锁的愿望讨论数据结构。本章讨论了并发容器,以帮助解决并发性的关键数据结构问题。一个相关的主题,线程本地存储(TLS)的使用,已经在第五章中讨论过了。
本章和下一章将介绍 TBB 的关键部分,这些部分有助于线程间的数据协调,同时避免第五章中的显式同步。我们这样做是为了以一种已经被证明能够扩展的方式推动我们自己编码。我们喜欢由 TBB 的开发人员精心制作实现的解决方案(为了帮助激发这对于正确性的重要性,我们讨论 A-B——一个从 200 页开始的问题)。我们应该注意算法的选择会对并行性能和实现的容易程度产生深远的影响。
明智地选择算法:并发容器不是万能的
当并行数据访问源于明确的并行策略时,它是最好的,其中一个关键部分是正确选择算法。受控访问(如并发容器所提供的)是有代价的:使容器“高度并发”不是免费的,甚至不总是可能的。当这种支持在实践中运行良好时,TBB 提供并发容器(队列、哈希表和向量)。TBB 并不试图支持“列表”和“树”等容器的并发性,在这些容器中,细粒度的共享将无法很好地扩展——并行性的更好机会在于修改算法和/或数据结构选择。
并发容器为容器提供了线程安全的版本,其中并发支持可以在并行程序中很好地工作。正如上一章所讨论的,它们提供了一个更高性能的选择,而不是使用一个周围有粗粒度锁的串行容器。TBB 容器通常提供细粒度的锁定或无锁实现,或者有时两者都提供。
关键数据结构基础
如果您熟悉散列表、无序映射、无序集合、队列和向量,那么您可能想跳过这一节,继续阅读“并发容器”。为了帮助回顾关键的基础知识,在开始讨论 TBB 如何支持并行编程之前,我们先简要介绍一下关键的数据结构。
无序关联容器
无序关联容器 ,用简单的英语来说,就叫做集合。我们也可以称之为“集合”然而,技术术语已经发展到使用 map、set 和 hash 表来表示各种类型的集合。
关联容器是数据结构,给定一个键,可以找到一个值,与那个键相关联。它们可以被认为是一个奇特的数组,我们称之为“关联数组”他们采用比简单的一系列数字更复杂的指数。除了Cost[1]
、Cost[2]
、Cost[3]
,我们可以想到Cost[Glass of Juice]
、Cost[Loaf of Bread]
、Cost[Puppy in the Window]
。
我们的关联容器可以通过两种方式特殊化:
-
地图 vs 布景:有没有值?还是只是一个键?
-
多个值:具有相同键的两个项目可以插入到同一个集合中吗?
地图与布景
我们所说的“地图”实际上只是一个附加了值的“集合”。想象一篮子水果(苹果、橘子、香蕉、梨、柠檬)。装有水果的装置可以告诉我们篮子里是否有特定类型的水果。一个简单的是或否我们可以在篮子里添加或移除一种水果。一个映射为此添加了一个值,通常是一个本身带有信息的数据结构。有了水果类型到集合(果篮)的映射,我们可以选择保存计数、价格和其他信息。除了简单的是或否之外,我们还可以询问Cost[Apple]
或Ripeness[Banana]
。如果值是具有多个字段的结构,那么我们可以查询多项内容,比如成本、成熟度和颜色。
多重值
在常规的“地图”或“集合”容器中,不允许使用与地图中已经存在的项目相同的键将项目插入到地图/集合中(确保唯一性),但在“多地图”和“多集合”版本中是允许的。在“多个”版本中,重复是允许的,但是我们失去了查找类似Cost[Apple] because the
key
Apple
在地图/集合中不再唯一的能力。
散列法
我们提到的一切(关联数组、映射/集合、单个/多个)通常都是使用散列函数实现的。要理解什么是散列函数,最好理解它的动机。考虑一个关联数组LibraryCardNumber[Name of Patron]
。数组LibraryCardNumber
返回给定姓名(指定为字符串)的顾客的图书卡号,该姓名作为索引提供。实现这种关联数组的一种方法是使用元素链表。不幸的是,查找一个元素需要在列表中逐个搜索匹配。这可能需要遍历整个列表,这在并行程序中是非常低效的,因为要争用对共享列表结构的访问。即使没有并行性,当插入一个条目时,验证没有其他条目具有相同的键需要搜索整个列表。如果列表有成千上万的顾客,这很容易需要大量的时间。更奇特的数据结构,比如树,可以改善一些但不是所有的问题。
相反,想象一个巨大的数组来存放数据。这个数组由传统的array[integer]
方法访问。这非常快。我们所需要的,是一个神奇的散列函数,它获取关联数组的索引(顾客的名字)并将其转换成我们需要的integer
。
无序的
我们确实以单词无序作为我们一直在讨论的关联容器类型的限定符。我们当然可以对键进行排序,并按照给定的顺序访问这些容器。没有什么能阻止这一点。例如,键可能是一个人的名字,我们想按字母顺序创建一个电话簿。
这里的单词 unordered 并不意味着我们在编程时不能考虑顺序。它确实意味着数据结构(容器)本身并没有为我们维护一个顺序。如果有一种“遍历”容器的方法(C++ 行话中的迭代,唯一的保证是我们将访问容器的每个成员一次,并且只访问一次,但是顺序是不确定的,并且可以随运行而变化,或者随机器而变化,等等。
并发容器
TBB 提供了高度并发的容器类,这些容器类对所有 C++ 线程化应用程序都很有用;TBB 并发容器类可以用于任何线程方法,当然也包括 TBB!
C++ 标准模板库最初设计时并没有考虑到并发性。通常,C++ STL 容器不支持并发更新,因此试图并发修改它们可能会导致容器损坏。当然,STL 容器可以包装在粗粒度的mutex
中,通过一次只让一个线程在容器上操作,使它们对于并发访问是安全的。然而,这种方法消除了并发性,从而限制了在性能关键代码中的并行加速。第五章中展示了使用互斥体进行保护的示例,以保护直方图中的元素增量。可以对非线程安全的 STL 例程进行类似的保护,以避免正确性问题。如果在性能关键部分没有这样做,那么性能影响可能很小。这是很重要的一点:集装箱到 TBB 并发集装箱的转换应该由需求驱动。并行使用的数据结构应该为并发性而设计,以便为我们的应用程序提供伸缩性。
TBB 中的并发容器提供了类似于标准模板库(STL)所提供的容器的功能,但是是以线程安全的方式提供的。例如,tbb::concurrent_vector
类似于std::vector
类,但是让我们安全地并行增长向量。如果只是并行读取,我们不需要并发容器;只有当我们有修改容器的并行代码时,我们才需要特殊的支持。
TBB 提供了几个容器类,旨在以兼容的方式替换相应的 STL 容器,允许多个线程同时调用同一个容器上的某些方法。这些 TBB 容器通过以下一种或两种方法提供了更高级别的并发性:
-
细粒度锁定:多线程通过只锁定那些真正需要锁定的部分来操作容器(如第五章中的直方图示例所示)。只要不同的线程访问不同的部分,它们就可以并发进行。
-
无锁技术:不同的线程考虑并纠正其他干扰线程的影响。
值得注意的是,TBB 并发容器的成本很低。它们通常比普通的 STL 容器有更高的开销,因此对它们的操作可能比对 STL 容器的操作花费更长的时间。当存在并发访问的可能性时,应该使用并发容器。然而,如果并发访问是不可能的,建议使用 STL 容器。也就是说,当并发容器带来的额外并发性加速超过了它们较慢的顺序性能时,我们就使用并发容器。
容器的接口与 STL 中的保持一致,除了为了支持并发性而需要修改的地方。我们可能会向前跳一会儿,这是一个很好的时间来考虑为什么有些接口不是线程安全的经典例子—,这是需要理解的重要一点!典型的例子(见图 6-9 )是需要一个新的非空弹出功能(称为try_pop
)用于队列,而不是依赖一个使用 STL 空测试的代码序列,如果测试返回非空则跟随一个弹出。这种 STL 代码中的危险是另一个线程可能正在运行,清空容器(在原始线程的测试之后,但在 pop 之前),因此创建一个竞争条件,其中 pop 实际上可能会阻塞。这意味着 STL 代码不是线程安全的。我们可以在整个序列周围设置一个锁,以防止在测试和弹出之间修改队列,但是众所周知,当在应用程序的并行部分使用这种锁时,会破坏性能。理解这个简单的例子(图 6-9 )将有助于阐明支持并行性需要什么。
像 STL 一样,TBB 容器是根据分配器参数进行模板化的。每个容器都使用这个分配器为用户可见的项目分配内存。TBB 的默认分配器是 TBB 提供的可伸缩内存分配器(在第七章中讨论)。不管指定了什么分配器,容器的实现也可以使用不同的分配器来实现严格的内部结构。
TBB 目前提供以下并发容器:
-
无序关联容器
-
无序地图(包括无序多地图)
-
无序集(包括无序多重集)
-
散列表
-
-
队列(包括有界队列和优先级队列)
-
矢量
为什么 TBB 容器分配器参数默认为 TBB?
所有 TBB 容器都支持分配器参数,它们默认为 TBB 可伸缩内存分配器(参见第七章)。
容器默认使用混合的tbb::cache_aligned_allocator
和tbb:tbb_allocator
。我们在本章中记录了缺省值,但是本书的附录 B 和 TBB 头文件是学习缺省值的资源。不需要链接 TBB 可伸缩分配器库(见第七章),因为当库不存在时,TBB 容器将默认使用malloc
。然而,我们应该链接 TBB 可伸缩分配器,因为仅仅链接性能可能会更好——如第七章所述,将它用作代理库特别容易。
图 6-1
并发无序关联容器的比较
并发无序关联容器
无序关联容器是一组实现哈希表变量的类模板。图 6-1 列出了这些容器及其主要区别特征。并发无序关联容器可用于存储任意元素,如整数或自定义类,因为它们是模板。TBB 提供了无序关联容器的实现,可以并发执行。
哈希映射(通常也称为哈希表)是一种使用哈希函数将键映射到值的数据结构。散列函数根据关键字计算索引,并且索引用于访问存储与关键字相关联的值的“桶”。
选择一个好的哈希函数非常重要!一个完美的哈希函数会将每个键分配给一个唯一的桶,这样不同的键就不会有冲突。然而实际上,散列函数并不完美,偶尔会为多个键生成相同的索引。这些冲突需要哈希表实现的某种形式的适应,这将引入一些开销-哈希函数应该设计为通过将输入哈希化为跨存储桶的几乎均匀的分布来最小化冲突。
散列表的优势来自于在一般情况下为搜索、插入和键提供O(1)
时间的能力。TBB 散列图的优点是支持并发使用,以提高正确性和性能。这假设使用了一个好的散列函数,一个不会对所使用的密钥造成很多冲突的函数。只要存在不完美的散列函数,或者如果散列表的维度不够好,理论上最差的情况O(n)
仍然存在。
在实际使用中,哈希映射通常比包括搜索树在内的其他表查找数据结构更有效。这使得散列映射成为多种用途的数据结构选择,包括关联数组、数据库索引、缓存和集合。
并发散列映射
TBB 提供了concurrent_hash_map
,它以一种允许多线程通过find
、insert,
和erase
方法并发访问值的方式将键映射到值。正如我们将在后面讨论的,tbb:: concurrent_hash_map
是为并行设计的,因此它的接口是线程安全的,不像我们将在本章后面讨论的 STL map/set
接口。
这些键是无序的。每个键在一个concurrent_hash_map
中最多有一个元素。该键可能有其他元素在运行中,但不在映射中。类型HashCompare
指定如何散列键和如何比较它们是否相等。正如通常对哈希表所期望的那样,如果两个键相等,那么它们必须哈希到相同的哈希代码。这就是为什么HashCompare
将比较和散列的概念结合到一个单独的对象中,而不是分别对待它们。这样做的另一个后果是,当哈希表不为空时,我们不需要更改键的哈希代码。
一个concurrent_hash_map
充当一个std::pair<const Key,T>
类型元素的容器。通常,当访问容器元素时,我们感兴趣的不是更新它就是读取它。模板类concurrent_hash_map
分别支持这两个目的,类accessor
和const_accessor
充当智能指针。访问者代表更新(写)访问。只要它指向一个元素,所有其他的尝试在表块中查找那个键,直到访问器完成。A const_accessor
类似,除了它代表只读访问。多个访问器可以同时指向同一个元素。在频繁读取元素而很少更新元素的情况下,这个特性可以极大地提高并发性。
我们分享一个使用图 6-2 和 6-3 中的concurrent_hash_map
容器的简单代码示例。我们可以通过减少元素访问的生存期来提高这个例子的性能。方法find
和insert
将一个accessor
或const_accessor
作为参数。选择告诉concurrent_hash_map
我们是请求更新还是只读访问。一旦方法返回,访问将持续到accessor
或const_accessor
被销毁。因为访问一个元素会阻塞其他线程,所以尽量缩短accessor
或const_accessor
的生命周期。为此,请尽可能在最里面的块中声明它。要在块结束之前释放访问,使用方法release
。图 6-5 显示了图 6-2 中循环体的返工,使用release
代替依赖破坏来结束螺纹寿命。方法remove(key)
也可以同时运行。它隐式请求写访问。因此,在移除密钥之前,它会等待对密钥的任何其他现存访问。
图 6-5
修改图 6-2 以减少存取器寿命,希望改善缩放
图 6-4
图 6-2 和 6-3 中示例程序的输出
图 6-3
散列表示例,第二部分,共 2 部分
图 6-2
散列表示例,第一部分,共 2 部分
哈希映射的性能提示
-
始终为哈希表指定初始大小。其中一个的缺省值将可怕地扩展!好的尺码肯定是从几百开始的。如果较小的大小似乎是正确的,那么由于缓存局部性,在小表上使用锁将在速度上具有优势。
-
检查你的散列函数——确保散列值的低位比特具有良好的伪随机性。特别是,您不应该使用指针作为键,因为由于对象对齐的原因,指针的低位通常会有一组 0 位。如果是这种情况,强烈建议将指针除以它所指向的类型的大小,从而移出始终为零的位,以支持变化的位。乘以一个质数,并移出一些低阶位,是一个可以考虑的策略。与任何形式的哈希表一样,相等的键必须具有相同的哈希代码,理想的哈希函数将键均匀地分布在哈希代码空间中。针对最佳散列函数的调优肯定是特定于应用程序的,但是使用 TBB 提供的缺省值往往会工作得很好。
-
如果可以避免,就不要使用访问器,并且在需要访问器时尽可能地限制它们的生存期(参见图 6-5 中的示例)。它们是有效的细粒度锁,在存在时会抑制其他线程,因此可能会限制伸缩。
-
使用 TBB 内存分配器(参见第七章)。如果您想强制容器的使用(不允许回退到 malloc),就使用
scalable_allocator
作为容器的模板参数——至少在测试性能时,在开发过程中有一个很好的完整性检查。
对map
/ multimap
和set
/ multiset
接口的并发支持
标准 C++ STL 定义了unordered_set, unordered_map, unordered_multiset,
和unordered_multimap
。这些容器的不同之处仅在于对其元素的约束。图 6-1 是比较我们对并发地图/集合支持的五种选择的便利参考,包括我们在代码示例中使用的tbb::concurrent_hash_map
(图 6-2 到 6-5 )。
STL 没有定义任何叫做“hash”的东西,因为 C++ 最初没有定义哈希表。对向 STL 添加哈希表支持的兴趣非常普遍,因此有广泛使用的 STL 版本,它们被扩展为包含哈希表支持,包括 SGI、gcc 和 Microsoft 的版本。没有标准,就能力和性能而言,“哈希表”或“哈希表”对 C++ 程序员来说意味着什么。从 C++11 开始,STL 中增加了一个散列表实现,并为该类选择了名称unordered_map
,以防止与预标准实现混淆和冲突。可以说名称unordered_map
更具描述性,因为它暗示了类的接口及其元素的无序本质。
最初的 TBB 哈希表支持早于 C++11,称为tbb:
:concurrent_hash_map
。这个散列函数仍然很有价值,不需要修改来符合标准。TBB 现在包括对unordered_map
和unordered_set
的支持,以反映 C++11 的增加,接口仅在需要支持并发访问时增加或调整。避免一些对并行不友好的接口是“推动我们”进行有效并行编程的一部分。附录 B 对细节进行了详尽的介绍,但为了实现更好的并行伸缩,有三个值得注意的调整,如下所示:
-
省略了需要 C++11 语言特性的方法(例如
rvalue
引用)。 -
C++ 标准函数的擦除方法以
unsafe_
为前缀,表示它们不是并发安全的(因为只有concurrent_hash_map
支持并发擦除)。这不适用于concurrent_hash_map
,因为不支持并发擦除。 -
bucket 方法(bucket 的计数、bucket 的最大计数、bucket 的大小以及对遍历 bucket 的支持)以
unsafe_
为前缀,提醒它们对于插入来说不是并发安全的。支持它们是为了与 STL 兼容,但如果可能的话,应该避免使用它们。如果使用的话,应该防止它们与插入同时使用。这些接口不适用于concurrent_hash_map
,因为 TBB 的设计者避免了这样的功能。
内置锁定与无可见锁定
容器concurrent_hash_map
和concurrent_unordered_*
在被访问元素的锁定方面有些不同。因此,在争用的情况下,它们可能会表现得非常不同。concurrent_hash_map
的访问器本质上是锁:accessor
是排他锁,const_accessor
是共享锁。基于锁的同步内置于容器的使用模型中,不仅保护了容器的完整性,还在一定程度上保护了数据的完整性。图 6-2 中的代码在向表中执行插入操作时使用了一个accessor
。
遍历这些结构是自找麻烦
我们在图 6-3 的末尾偷偷加入了一些并发不安全的代码,当我们遍历哈希表来转储它的时候。如果在我们走桌子的时候插入或删除,这可能会有问题。在我们的辩护中,我们只会说“这是调试代码,我们不在乎!”但是,经验告诉我们,像这样的代码很容易进入非调试代码。当心!
TBB 的设计者为了调试的目的给concurrent_hash_map
留下了迭代器,但是他们故意不让我们用迭代器作为其他成员的返回值。
不幸的是,STL 以我们应该学会抵制的方式诱惑着我们。concurrent_unordered_*
容器不同于concurrent_hash_map
——API 遵循关联容器的 C++ 标准(记住,最初的 TBB concurrent_hash_map
早于 C++ 对并发容器的任何标准化)。添加或查找数据的操作返回一个迭代器,所以这诱使我们用它进行迭代。在一个并行程序中,我们冒着与地图/集合上的其他操作同时发生的风险。如果我们屈服于诱惑,保护数据完整性完全是我们程序员的事,容器的 API 没有帮助。有人可能会说 C++ 标准容器提供了额外的灵活性,但是缺乏concurrent_hash_map
提供的内置保护。如果我们避免使用从添加或查找操作返回的迭代器,除了引用我们查找的项目,STL 接口很容易并发使用。如果我们屈服于诱惑(我们不应该!),那么我们就有很多关于应用程序中并发更新的思考要做。当然,如果没有更新发生——只有查找——那么使用迭代器就没有并行编程问题。
并发队列:常规、有界和优先级
队列是一种有用的数据结构,可以通过 push(添加)和 pop(删除)操作在队列中添加或删除条目。无界队列接口提供了一个“try pop ”,它告诉我们队列是否为空,是否没有值从队列中弹出。这使我们不再编写自己的逻辑来通过测试空来避免阻塞弹出——这是一种不安全的线程操作(见图 6-9 )。在多个线程之间共享一个队列可能是在线程之间传递工作项目的有效方法——保存“工作”的队列可以添加工作项目以请求将来的处理,并由想要进行处理的任务移除。
通常,队列以先进先出(FIFO)的方式运行。如果我从一个空队列开始,执行一个push(10)
,然后执行一个push(25),
,那么第一个弹出操作将返回10
,第二个弹出操作将返回一个25
。这与堆栈的行为有很大不同,堆栈通常是后进先出的。但是,我们不是在这里谈论堆栈!
我们在图 6-6 中展示了一个简单的例子,它清楚地显示了弹出操作返回值的顺序与推送操作将它们添加到队列中的顺序相同。
图 6-6
使用简单(FIFO)队列的示例
队列有两种变化:限制和优先级。绑定增加了限制队列大小的概念。这意味着如果队列已满,可能无法进行推送。为了处理这个问题,有界队列接口为我们提供了一些方法,让 push 等待,直到它可以添加到队列中,或者提供一个“尝试 push”操作,如果可以或者让我们知道队列已满,就进行 push。默认情况下,有界队列是无界的!如果我们想要一个有界队列,我们需要使用concurrent_bounded_queue
和调用方法set_capacity
来设置队列的大小。我们在图 6-7 中展示了有界队列的一个简单用法,其中只有前六个项目被推入队列。我们可以在try_push
上增加一个测试,然后做点什么。在这种情况下,当弹出操作发现队列为空时,我们有程序 print ***
。
图 6-7
这个例程扩展了我们的程序,以显示有界队列的使用情况
优先级通过有效地对队列中的项目进行排序,增加了先进先出的灵活性。如果我们没有在代码中指定优先级,默认的优先级是std::less<T>
。这意味着 pop 操作将返回队列中值最高的项。
图 6-8 显示了优先级使用的两个例子,一个默认为std:: less<int
>,另一个明确指定std::greater<int>
。
图 6-8
这些例程扩展了我们的程序,以显示优先级排队
正如前面三幅图中的例子所示,为了实现队列的这三种变化,TBB 提供了三个容器类:concurrent_queue
、concurrent_bounded_queue
和concurrent_priority_queue
。所有并发队列都允许多个线程同时推送和弹出项目。这些接口与 STL std::queue
或std::priority_queue
类似,除了它们必须不同以使队列的并发修改安全。
队列中的基本方法是push
和try_pop
。push
方法的工作原理和std::queue
一样。需要注意的是不支持front
或back
方法,因为它们在并发环境中是不安全的,因为这些方法会返回对队列中某项的引用。在并行程序中,队列的前面或后面可能会被另一个并行线程改变,使得使用front
或back
变得毫无意义。
类似地,对于未绑定的队列,不支持 pop 和空测试——相反,方法try_pop
被定义为如果项目可用,则 pop 一个项目,并返回一个true
状态;否则,它不返回任何项目,并返回一个状态false
。test-for-empty 和 pop 方法被组合成一个方法,以鼓励线程安全编码。对于有界队列,除了可能阻塞的push
方法之外,还有一个非阻塞的try_push
方法。这些帮助我们避免使用size
方法来查询队列的大小。一般来说,应该避免使用size
方法,尤其是当它们是顺序程序的延续时。因为在并行程序中,队列的大小可以同时改变,所以如果使用size
方法,需要仔细考虑。首先,当队列为空并且有挂起的弹出方法时,TBB 可以为size
方法返回一个负值。当size
为零或更小时empty
方法为真。
边界尺寸
对于concurrent_queue
和concurrent_priority_queue
,容量是无限的,受到目标机器上的内存限制。concurrent_bounded_queue
提供了对边界的控制——一个关键特性是push
方法会一直阻塞,直到队列有空间为止。有界队列有助于减缓供应商的速度,使其与消耗速度相匹配,而不是让队列不受约束地增长。
concurrent_bounded_queue
是唯一一个提供pop
方法的concurrent_queue_*
容器。pop
方法将阻塞,直到一个项目变得可用。一个push
方法只能被一个concurrent_bounded_queue
阻塞,所以这个容器类型也提供了一个叫做try_push.
的非阻塞方法
通过使用limiter_node
,这种限制速率匹配的概念也存在于流程图(参见第三章)中,以避免内存溢出或内核过载。
优先排序
优先级队列基于各个排队项目的优先级来维护队列中的排序。正如我们前面提到的,普通队列有先进先出策略,而优先级队列对其项目进行排序。我们可以提供自己的比较来改变默认的排序。例如,使用std::greater<T>
会导致最小的元素成为下一个被pop
方法检索的元素。我们在图 6-8 的示例代码中正是这样做的。
保持线程安全:尽量忘记顶部、大小、空、前面、后面
需要注意的是没有top
方法,我们可能应该避免使用size
和empty
方法。并发使用意味着所有三个线程的值都可能由于其他线程中的 push/pop 方法而改变。此外,虽然支持clear
和swap
方法,但它们不是线程安全的。当将一个std::priority_queue
用法转换为tbb::concurrent_priority_queue
时,TBB 强迫我们使用top
重写代码,因为返回的元素可能会被并发的 pop 无效。因为返回值不会受到并发性的威胁,所以 TBB 支持size
、empty
和swap
的std::priority_queue
方法。但是,我们建议仔细检查在并发应用程序中使用这两个函数中的任何一个是否明智,因为依赖这两个函数中的任何一个都可能暗示需要为并发性重写代码。
图 6-9
STL 和 TBB 优先级队列代码的并排比较显示了使用try_pop
而不是top
和pop
的动机。在这个没有并行的例子中,两者合计为 50005000,但是 TBB 是可伸缩的,并且是线程安全的。
迭代程序
仅出于调试目的,所有三个并发队列都提供有限的迭代器支持(iterator
和const_iterator
类型)。这种支持仅仅是为了允许我们在调试过程中检查队列。iterator
和const_iterator
类型都遵循前向迭代器的 STL 惯例。迭代顺序是从最近推送的到最近推送的。修改队列会使引用它的所有迭代器失效。迭代器相对较慢。它们应该只用于调试。使用示例如图 6-10 所示。
图 6-10
遍历并发队列的示例调试代码——注意begin
和end
上的unsafe_
前缀,以强调这些方法的仅调试非线程安全性质。
为什么使用这个并发队列:A-B——一个问题
我们在本章开始时提到,拥有由并行专家编写的供我们“使用”的容器具有重要的价值我们都不想为每个应用程序重新发明好的可伸缩实现。作为动机,我们岔开话题,提到 A-B——一个问题——一个并行性出错的经典计算机科学例子!乍一看,并发队列似乎很容易编写自己的队列。不是的。使用 TBB 的concurrent_queue
,或者任何其他研究充分、实现良好的并发队列,是一个好主意。尽管这种经历令人羞愧,但我们不会是第一个知道这并不像我们天真地认为的那么简单的人。如果A-B-A
问题(见侧栏)阻碍了我们的意图,那么第五章中的更新习语(compare_and_swap
)是不合适的。当试图为链接数据结构(包括并发队列)设计非阻塞算法时,这是一个常见的问题。TBB 的设计者对 A-B 有一个解决方案——一个已经打包在并发队列解决方案中的问题。我们可以依赖它。当然,它是开源代码,所以如果你感到好奇,你可以在代码中寻找答案。如果你查看源代码,你会发现竞技场管理(第十二章的主题)也必须处理 ABA 问题。当然,你可以直接使用 TBB,而不需要了解这些。我们只是想强调,解决并发数据结构并不像看起来那么简单——因此我们喜欢使用 TBB 支持的并发数据结构。
A-B-一个问题
理解 A-B 问题是训练我们在设计自己的算法时思考并发性含义的一个重要方法。虽然 TBB 在实现并发队列和其他 TBB 结构时避免了 A-B-A 问题,但它提醒我们需要“并行思考”
当一个线程检查一个位置以确保其值为A
并且仅在该值为A
时才继续更新时,就会出现A-B-A
问题。问题是,如果其他任务以第一个任务没有检测到的方式改变相同的位置,这是否是一个问题:
-
一个任务从
globalx
读取一个值A
。 -
其他任务将
globalx
从A
变为B
,然后回到A
。 -
步骤 1 中的任务执行其
compare_and_swap
,读取A
,因此没有检测到B
的中间变化。
如果该任务在假设自该任务第一次读取该位置以来该位置没有改变的情况下错误地继续进行,则该任务可能继续破坏该对象或者得到错误的结果。
考虑一个链表的例子。假设一个链表W(1)
→ X(9)
→ Y(7)
→ Z(4)
,其中字母是节点位置,数字是节点中的值。假设某个任务遍历列表以找到要出列的节点X
。该任务获取下一个指针X.next
(即Y
),并将其放入W.next
。但是,在交换完成之前,任务会暂停一段时间。
暂停期间,其他任务繁忙。它们使X
出列,然后碰巧重用相同的内存,对节点X
的新版本进行排队,以及在某个时间点使Y
出列并添加Q
。现在,名单是W(1)
→ X(2)
→ Q(3)
→ Z(4)
。
一旦原任务最终醒来,发现W.next
仍然指向X
,于是换出W.next
成为Y
,从而把链表搞得一塌糊涂。
如果原子操作为我们的算法提供了足够的保护,那么它们就是我们要走的路。如果这个问题会毁了我们的一天,我们需要找到一个更复杂的解决方案。tbb::concurrent_queue
具有必要的额外复杂性来实现这一点!
何时不使用队列:想想算法!
队列在并行程序中被广泛用于缓冲消费者和生产者。在使用显式队列之前,我们需要考虑使用parallel_do
或pipeline
来代替(参见第二章)。这些选项通常比队列更有效,原因如下:
-
队列天生就是瓶颈,因为它们必须保持一个顺序。
-
如果队列为空,则弹出值的线程将停止,直到值被推入。
-
队列是一种被动的数据结构。如果一个线程推送一个值,它可能需要一段时间才能弹出该值,与此同时,该值(及其引用的任何内容)在缓存中变成 cold 。或者更糟的是,另一个线程弹出该值,并且该值(及其引用的任何内容)必须被移动到另一个处理器内核。
相比之下,parallel_do
和pipeline
避免了这些瓶颈。因为它们的线程是隐式的,所以它们优化了工作线程的使用,这样它们就可以做其他工作,直到一个值出现。他们还试图在缓存中保存热门项目。例如,当另一个工作项目被添加到一个parallel_do
中时,它被保存在添加它的线程的本地,除非在热线程处理它之前另一个空闲线程可以窃取它。这样,项目更经常地被热线程处理,从而减少了获取数据的延迟。
*### 并发向量
TBB 开设了一门叫做concurrent_vector
的课程。一个concurrent_vector<T>
是一个可动态增长的T
数组。增长一个concurrent_vector
是安全的,即使其他线程也在操作它的元素,甚至自己也在增长它。为了安全的并发增长,concurrent_vector
有三种方法支持动态数组的常见用法:push_back
、grow_by
和grow_to_at_least
。
图 6-11 显示了concurrent_vector
的简单用法,图 6-12 显示了在向量内容的转储中,并行线程同时添加的效果。如果按数字顺序排序,同一程序的输出将被证明是相同的。
什么时候用 tbb::concurrent_vector 代替 std::vector
concurrent_vector<T>
的关键价值在于它能够同时增长一个向量,并且能够保证元素不会在内存中移动。
concurrent_vector
确实比std::vector.
有更多的开销,因此,当我们需要在其他访问正在进行(或可能正在进行)或要求某个元素永远不移动时动态调整其大小的能力时,我们应该使用concurrent_vector
。
图 6-12
左侧是使用for
(非并行)时生成的输出,右侧显示使用parallel_for
(并发推入向量)时的输出。
图 6-11
并发向量小示例
元素从不移动
在数组被清空之前,concurrent_vector
永远不会移动元素,即使对于单线程代码来说,这也比 STL std::vector
更有优势。与std::vector
不同的是,concurrent_vector
在成长时从不移动现有元素。容器分配一系列连续的数组。第一个保留、增长或分配操作决定了第一个数组的大小。使用少量元素作为初始大小会导致跨缓存行的碎片,这可能会增加元素访问时间。shrink_to_fit()
将几个较小的数组合并成一个连续的数组,这样可以提高访问时间。
并发向量的并发增长
虽然并发增长从根本上与理想的异常安全不兼容,但concurrent_vector
确实提供了一个实用的异常安全级别。元素类型必须有一个从不抛出异常的析构函数,如果构造器可以抛出异常,那么析构函数必须是非虚拟的,并且可以在零填充内存上正确工作。
push_back(x)
方法安全地将x
附加到向量上。grow_by(n)
方法安全地追加用T().
初始化的n
个连续元素。两种方法都返回指向第一个追加元素的迭代器。每个元素都被初始化with T()
。以下例程将 C 字符串安全地附加到共享向量中:
grow_to_at_least(n)
如果矢量较短,则将其增大到n
的大小。对 growth 方法的并发调用不一定按照元素附加到 vector 的顺序返回。
size()
返回 vector 中元素的数量,这可能包括仍在通过方法push_back
、grow_by
或grow_to_at_least
进行并行构造的元素。前面的例子使用了std::copy
和迭代器,而不是strcpy
和指针,因为concurrent_vector
中的元素可能不在连续的地址上。在concurrent_vector
增长时使用迭代器是安全的,只要迭代器不会超过end()
的当前值。然而,迭代器可能引用正在进行并发构造的元素。所以要求我们同步建设,同步接入。
对concurrent_vector
的操作在增长方面是并发安全的,而不是为了清除或销毁向量。如果concurrent_vector
号上有其他操作正在进行,千万不要调用clear() i
。
摘要
在这一章中,我们讨论了 TBB 支持的三种关键数据结构(散列/映射/集合、队列和向量)。来自 TBB 的这种支持提供了线程安全(可以并发运行)以及可伸缩性良好的实现。我们提供了要避免的事情的建议,因为它们往往会在并行程序中引起麻烦——包括使用 map/set 返回的迭代器来处理除了被查找的项目之外的任何事情。我们回顾了 A-B——这个问题既是我们使用 TBB 而不是自己编写的动机,也是我们在并行程序共享数据时需要考虑的一个极好的例子。
与其他章节一样,完整的 API 在附录 B 中有详细说明,图中显示的代码都是可下载的。
尽管对容器的并行使用有很好的支持,但我们不能过分强调这样一个概念,即通过算法来最小化任何类型的同步对于高性能并行编程都是至关重要的。如果您可以通过使用parallel_do
、pipeline
、parallel_reduce
等等来避免共享数据结构,正如我们在“何时不使用队列:考虑算法”一节中提到的–您可能会发现您的程序伸缩性更好。我们在本书中以多种方式提到这一点,因为思考这一点对于最有效的并行编程非常重要。
开放存取本章根据知识共享署名-非商业-非专用 4.0 国际许可协议(http://Creative Commons . org/licenses/by-NC-nd/4.0/)的条款进行许可,该协议允许以任何媒体或格式进行任何非商业使用、共享、分发和复制,只要您适当注明原作者和来源,提供知识共享许可协议的链接,并指出您是否修改了许可材料。根据本许可证,您无权共享从本章或其部分内容派生的改编材料。
本章中的图像或其他第三方材料包含在该章的知识共享许可中,除非该材料的信用额度中另有说明。如果材料未包含在本章的知识共享许可中,并且您的预期用途不被法定法规允许或超出了允许的用途,您将需要直接从版权所有者处获得许可。*
七、可扩展内存分配
本章讨论了任何并行程序的一个关键部分:可伸缩内存分配,包括使用new
以及对malloc
、calloc
等的显式调用。无论我们是否使用线程构建模块(TBB)的任何其他部分,都可以使用可扩展内存分配。除了可以直接使用的接口之外,TBB 还提供了一种“代理”方法来自动替换 C/C++ 函数进行动态内存分配,这是一种简单、有效、流行的方法,可以在不修改任何代码的情况下提高性能。不管你对 C++ 的使用有多“现代”,这都是重要且有效的,特别是不管你是使用现代且受鼓励的std::make_shared
,还是现在不受欢迎的new
和malloc
。使用可伸缩内存分配器的性能优势是显著的,因为它们直接解决了限制可伸缩性和错误共享风险的问题。TBB 是第一批广泛使用的可伸缩内存分配器之一,这在很大程度上是因为它与 TBB 一起免费提供,以帮助强调在任何并行程序中包含内存分配考虑因素的重要性。它现在仍然非常流行,并且是可用的最好的可伸缩内存分配器之一。
现代 C++ 编程(支持智能指针)与并行思维相结合,鼓励我们显式地使用std::allocate_shared
或隐式地使用std::make_shared
来使用 TBB 可伸缩内存分配器。
现代 C++ 内存分配
虽然性能是并行编程特别感兴趣的,但是正确性是所有应用程序的一个关键话题。内存分配/释放问题是应用程序中错误的一个重要来源,这导致了许多 C++ 标准的增加和被认为是现代 C++ 编程的转变!
现代 C++ 编程鼓励使用托管内存分配,在 C++11 中引入了智能指针(make_shared
、allocate_shared
等)。)和不鼓励大量使用malloc
或new
。从这本书的第一章开始,我们就在例子中使用了std:
:make_shared
。C++17 中添加的std::aligned_alloc
提供了缓存对齐以避免错误共享,但没有解决可伸缩内存分配问题。C++20 中有许多额外的功能,但是没有对可伸缩性的明确支持。
TBB 继续为并行程序员提供这一关键部分:可伸缩内存分配。TBB 以一种完全符合所有版本的 C++ 和 C 标准的方式做到了这一点。TBB 支持的核心和灵魂可以被描述为线程内存池。这种池化避免了由不寻求避免缓存之间不必要的数据转移的内存分配所导致的性能下降。TBB 还提供结合缓存对齐的可扩展内存分配,这提供了比简单使用std::aligned_alloc
更好的可扩展属性。缓存对齐不是默认行为,因为不加选择的使用会大大增加内存使用。
正如我们将在本章中讨论的,可伸缩内存分配的使用对性能至关重要。std::make_shared
没有提供对分配器的规范,但是有一个相应的std::allocate_shared
,它允许对分配器进行规范。
本章主要关注可伸缩内存分配器,无论为应用程序选择何种 C++ 内存分配方式,都应该使用可伸缩内存分配器。具有并行思想的现代 C++ 编程会鼓励用户通过 TBB 可伸缩内存分配器显式使用std::allocate_shared
,或者通过覆盖默认的new
来使用 TBB 可伸缩内存分配器,从而通过 TBB 隐式使用std::make_shared
。注意,std::make_shared
不受特定类的new
操作符的影响,因为它实际上分配了更大的内存块来处理类的内容和簿记的额外空间(具体来说,是为了使它成为智能指针而添加的原子)。这就是为什么覆盖默认的new
(使用 TBB 分配器)将足以影响std::make_shared
。
图 7-1
使用 TBB 可伸缩内存分配器的方法
可伸缩内存分配:什么
本章分四类讨论 TBB 的可扩展内存能力,如图 7-1 所示。来自所有四个类别的特征可以自由混合;我们将它们分成几类只是为了解释所有的功能。C/C++ 代理库是目前使用可伸缩内存分配器最流行的方式。
可伸缩内存分配器与 TBB 的其他部分完全分离,因此我们为并发使用选择的内存分配器与我们选择的并行算法和容器模板无关。
可伸缩内存分配:为什么
虽然本书的大部分内容向我们展示了如何通过并行工作来提高程序的速度,但是非线程感知的内存分配和释放会使我们的辛苦工作付之东流!在并行程序中,仔细分配内存有两个主要问题:分配器的争用和缓存效应。
当使用普通的非线程分配器时,内存分配可能会成为多线程程序中的一个严重瓶颈,因为每个线程都会为从单个全局堆中分配和取消分配内存而竞争一个全局锁。以这种方式运行的程序是不可伸缩的。事实上,由于这种争用,大量使用内存分配的程序实际上可能会随着处理器内核数量的增加而变慢!可伸缩内存分配器通过使用更复杂的数据结构来很大程度上避免争用,从而解决了这个问题。
另一个问题是缓存效应,这是因为内存的使用在硬件中有一个底层的数据缓存机制。因此,程序中的数据使用将暗示数据需要缓存在哪里。如果我们为线程 B 分配内存,并且分配器给了我们线程 A 最近释放的内存,那么很有可能我们无意中导致数据从一个缓存复制到另一个缓存,这可能会不必要地降低应用程序的性能。此外,如果单独线程的内存分配靠得太近,它们可能会共享一条缓存线。我们可以把这种共享描述为真共享(共享同一个对象)或者假共享(没有对象被共享,但是对象恰好落在同一个缓存行)。任何一种类型的共享都会对性能产生特别显著的负面影响,但是错误共享尤其令人感兴趣,因为它是可以避免的,因为没有打算共享。可伸缩内存分配器通过使用类cache_aligned_allocator<T>
始终从缓存行开始分配,并维护每个线程的堆(如果需要,会不时重新平衡),从而避免错误共享。这个组织也有助于解决先前的争用问题。
使用可伸缩内存分配器的好处很容易就能提升 20-30%的性能,我们甚至听说在极端情况下,通过简单地重新链接可伸缩内存分配器,程序性能提高了 4 倍。
使用填充避免错误共享
如果数据结构的内部由于错误共享而导致问题,则需要填充。从第五章开始,我们使用了直方图示例。直方图的桶和桶的锁都是可能的数据结构,它们在存储器中被足够紧密地打包,以使一个以上的任务更新单个高速缓存行中的数据。
在数据结构中,填充的概念是将元素分隔开,这样我们就不会共享相邻的元素,而这些相邻的元素会通过多个任务进行更新。
关于假共享,我们要采取的第一个措施是,在声明如图 7-2 所示的共享直方图(见图 5-20 )时,依靠tbb::cache_aligned_allocator
,而不是std::allocator
或malloc
。
图 7-2
原子的简单直方图向量
然而,这只是对齐直方图向量的开始,并确保hist_p[0]
将位于缓存行的开始。这意味着hist_p[0], hist_p[1], ... , hist_p[15]
存储在同一个缓存行中,当一个线程递增hist_p[0]
而另一个线程递增hist_p[15]
时,这就转化为假共享。为了解决这个问题,我们需要确保直方图的每个位置,每个库,都占据了一个完整的缓存行,这可以通过使用图 7-3 所示的填充策略来实现。
图 7-3
使用原子的直方图向量中的填充来消除假共享
正如我们在图 7-3 中所看到的,二进制数组hist_p
现在是一个structs
的向量,每个二进制数组包含原子变量,但也是一个 60 字节的虚拟数组,它将填充一个缓存行的空间。因此,这个代码是依赖于架构的。在当今的英特尔处理器中,高速缓存行是 64 字节,但是你可以找到假定为 128 字节的假共享安全实现。这是因为高速缓存预取(当请求高速缓存行“i
”时,高速缓存行“i+1
”)是一种常见的技术,并且这种预取在某种程度上等同于 128 字节大小的高速缓存行。
我们的无伪共享数据结构占用的空间是原来的 16 倍。这是计算机编程中经常出现的时空权衡的又一个例子:现在我们占用了更多的内存,但代码却更快了。其他的例子有较小的代码与循环展开,调用函数与函数内联,或者处理压缩数据与未压缩数据。
等等!bin 结构的前一个实现是不是有点单调?嗯,的确是!一个不太硬的解决方案是这样的:
因为sizeof()
是在编译时计算的,所以我们可以对其他填充的数据结构使用相同的结构,在这些数据结构中,实际的有效载荷(本例中为计数)具有不同的大小。但是我们知道 C++ 标准中有一个更好的解决方案:
由于使用了alignas()
方法,这保证了hist_p
的每个库占用了一个完整的缓存行。还有一件事!我们喜欢编写可移植的代码,对吗?如果在不同的或未来的体系结构中,高速缓存行大小不同,该怎么办。没问题,C++17 标准有我们正在寻找的解决方案:
太好了,假设我们已经修复了假分享的问题,那么真分享的呢?
两个不同的线程最终将增加同一个容器,这将从一个高速缓存乒乓到另一个。我们需要一个更好的主意来解决这个问题!当我们讨论私有化和缩减时,我们在第五章展示了如何处理这个问题。
可伸缩内存分配备选方案:哪种
如今,TBB 并不是可伸缩内存分配的唯一选择。虽然我们非常喜欢它,但我们将在本节中介绍最受欢迎的选项。当使用 TBB 进行并行编程时,我们必须使用可伸缩的内存分配器,不管它是由 TBB 还是其他公司提供的。使用 TBB 编写的程序可以利用任何内存分配器解决方案。
TBB 是第一个流行的并行编程方法,它与其他并行编程技术一起促进了可伸缩内存分配,因为 TBB 的创造者了解在任何并行程序中包含内存分配考虑的重要性。TBB 内存分配器今天仍然非常流行,并且肯定仍然是可用的最好的可伸缩内存分配器之一。
无论我们是否使用线程构建模块(TBB)的任何其他部分,都可以使用 TBB 可伸缩内存分配器。同样,TBB 可以在任何可伸缩的内存分配器上运行。
TBB 可伸缩内存分配器最流行的替代品是jemalloc
和tcmalloc
。就像 TBB 的可扩展内存分配器一样,有一些替代malloc
的方法,强调避免碎片,同时提供可扩展的并发支持。所有这三个都是开放源码的,具有自由许可(BSD 或 Apache)。
有些人会告诉你,他们已经将应用程序的tbbmalloc
与tcmalloc
和jeamalloc
进行了比较,发现它比他们的应用程序更优越。这是很常见的。然而,有一些人选择jemalloc
或tcmalloc
或llalloc
,即使他们广泛使用 TBB 的其他地方。这个也行。这是你的选择。
jemalloc
是 FreeBSD libc
分配器。最近,增加了额外的开发人员支持特性,如堆分析和广泛的监控/调优挂钩。jemalloc
为脸书所用。
tcmalloc
是谷歌gperftools
的一部分,后者包括tcmalloc
和一些性能分析工具。tcmalloc
为谷歌所用。
作为一个开源的无锁内存分配器,可以免费获得,也可以购买与闭源软件一起使用。
单个应用程序的行为,特别是内存分配和释放的模式,使得不可能从这些选项中选出一个万能的赢家。我们确信,任何对TBBmalloc, jemalloc, and tcmalloc
的选择都将远远优于默认的malloc
函数或new
操作符,如果它们是不可伸缩的(FreeBSD 使用jemalloc
作为它的默认 malloc)。
编译注意事项
使用英特尔编译器或gcc
编译程序时,最好传入以下标志:
-
-fno-builtin-malloc
(在 Windows 上:/Qfno-builtin-malloc
) -
-fno-builtin-calloc
(在 Windows 上:/Qfno-builtin-calloc
) -
-fno-builtin-realloc
(在 Windows 上:/Qfno-builtin-realloc
) -
-fno-builtin-free
(在 Windows 上:/Qfno-builtin-free
)
这是因为编译器可能会进行一些优化,假设它正在使用自己的内置函数。当使用其他内存分配器时,这些假设可能不成立。不使用这些标志可能不会导致问题,但为了安全起见,这并不是一个坏主意。查看您最喜欢的编译器的文档可能是明智的。
最流行的用法(C/C++ 代理库):如何使用
使用代理方法,我们可以全局替换new/delete
和malloc/calloc/realloc/free/etc
。具有动态内存接口替换技术的例程。这种自动替换动态内存分配的malloc
和其他 C/C++ 函数的方式是目前使用 TBB 可伸缩内存分配器功能最流行的方式。也很有效。
我们可以替换malloc/calloc/realloc/free/
等。(完整列表见图 7-4 )和new/delete
通过使用tbbmalloc_proxy
库。对于大多数程序来说,使用这种方法既简单又足够了。每个操作系统上使用的机制的细节略有不同,但最终效果在任何地方都是一样的。库名如图 7-5 所示;这些方法的总结如图 7-6 所示。
图 7-6
使用代理库的方法
图 7-5
代理库的名称
图 7-4
由代理替换的例程列表
Linux:
malloc/新代理库的使用
在 Linux 上,我们可以通过使用LD_PRELOAD
环境变量在程序加载时加载代理库(不改变可执行文件,如图 7-7 所示)或者通过将主可执行文件与代理库(-ltbbmalloc_proxy
链接来进行替换。Linux 程序加载器必须能够在程序加载时找到代理库和可伸缩内存分配器库。为此,我们可以在LD_LIBRARY_PATH
环境变量中包含包含库的目录,或者将其添加到/etc/ld.so.conf
。动态内存替换有两个限制:(1)不支持glibc
内存分配挂钩,如__malloc_hook
;以及(2) Mono
(基于微软.NET
框架的开源实现)。
MAC OS:malloc/新代理库用法
在 macOS 上,我们可以通过使用DYLD_INSERT_LIBRARIES
环境变量在程序加载时加载代理库(不改变可执行文件,如图 7-7 所示),或者通过将主可执行文件与代理库(-ltbbmalloc_proxy
)链接来进行替换。macOS 程序加载器必须能够在程序加载时找到代理库和可伸缩内存分配器库。为此,我们可以在DYLD_LIBRARY_PATH
环境变量中包含包含库的目录。
图 7-7
Environment
注入 TBB 可扩展内存分配器的变量
好奇者的实现洞察(非必读):TBB 有一个聪明的方法来克服使用DYLD_INSERT_LIBRARIES
需要使用平面名称空间来访问共享库符号的事实。通常,如果应用程序是用两级名称空间构建的,这种方法将不起作用,并且强制使用平面名称空间可能会导致崩溃。TBB 通过这样安排来避免这种情况,当libtbbmalloc_proxy
库被加载到进程中时;它的静态构造器被调用,并为 TBB 内存分配例程注册了一个内存分配区。这允许将来自标准 C++ 库的内存分配例程调用重定向到 TBB 可伸缩分配器例程中。这意味着应用程序不需要使用 TBB malloc
库符号;它继续调用标准的libc
例程。因此,名称空间没有问题。macOS malloc zones 机制还允许应用程序拥有多个内存分配器(例如,由不同的库使用)并正确管理内存。这保证了 TBB 将使用相同的分配器进行分配和取消分配。这是一种安全措施,可以防止由于调用另一个分配器分配的内存对象的释放例程而导致崩溃。
windows:malloc/新代理库用法
在 Windows 上,我们必须修改我们的可执行文件。我们可以通过在源代码中添加一个#include
来强制加载代理库,或者使用某些链接器选项,如图 7-8 所示。Windows 程序加载器必须能够在程序加载时找到代理库和可伸缩内存分配器库。为此,我们可以在PATH
环境变量中包含包含库的目录。
包括tbbmalloc_proxy.h>
到任何二进制文件的源(在应用程序启动时加载):
#include <tbb/tbbmalloc_proxy.h>
或者将以下参数添加到二进制文件的链接器选项中(在应用程序启动期间加载)。可以为应用程序启动时加载的 EXE 文件或 DLL 指定它们:
图 7-8
在 Windows 上使用代理库的方法(注意:win32 比 win64 多了一个下划线)
测试我们的代理库的使用
作为一个简单的复查,看看我们的程序是否利用了更快的分配,我们可以在多核机器上使用图 7-9 中的测试程序。在图 7-10 中,我们展示了我们如何运行这个小测试,以及我们在运行 Ubuntu Linux 的四核虚拟机上看到的时间差异。在图 7-11 中,我们展示了我们如何运行这个小测试,以及我们在四核 iMac 上看到的时间差异。在 Windows 上,使用四核英特尔 NUC(酷睿 i7)上的 Visual Studio“性能分析器”,我们看到在没有可扩展内存分配器的情况下时间为 94 毫秒,在有可扩展内存分配器的情况下时间为 50 毫秒(将#include <tbb/tbbmalloc_proxy.h>
添加到tbb_mem.cpp
)。所有这些运行显示了这个小测试如何验证可伸缩内存分配器的注入正在工作(对于new
/ delete
)并产生不小的性能提升!改为使用malloc()
和free()
的微小变化显示了类似的结果。我们将它作为tbb_malloc.cpp
包含在与本书相关的示例程序下载中。
示例程序确实使用了大量的堆栈空间,因此“ulimit –s unlimited
”(Linux/MAC OS)或“/STACK:10000000
”(Visual Studio:Properties>配置属性>链接器>系统>堆栈保留大小)对于避免直接崩溃非常重要。
图 7-12
TBB 可伸缩内存分配器提供的功能
图 7-11
在四核 iMac (macOS)上运行和计时 tbb_mem.cpp
图 7-10
在四核虚拟 Linux 机器上运行和计时tbb_mem.cpp
图 7-9
new
/ delete
速度的小测试程序(tbb_mem.cpp
C 函数:C 的可伸缩内存分配器
图 7-12 中列出的一组函数为可伸缩内存分配器提供了一个 C 级接口。由于 TBB 编程使用 C++,这些接口不是为 TBB 用户准备的——它们是为 C 代码准备的。
每个分配例程scalable_x
的行为类似于库函数x
。这些程序形成了两个系列,如图 7-13 所示。由一个家族中的scalable_x
函数分配的存储空间必须由同一家族中的scalable_x
函数释放或调整大小,而不是由 C 标准库函数释放。类似地,任何由 C 标准库函数或 C++ new
分配的存储空间都不应该被scalable_x
函数释放或调整大小。
这些功能由特定的#include <tbb/scalable_allocator.h>"
定义。
图 7-14
TBB 可伸缩内存分配器提供的类
图 7-13
按族耦合分配-解除分配功能
C++ 类:C++ 的可伸缩内存分配器
虽然代理库提供了一个采用可伸缩内存分配的一揽子解决方案,但它都是基于我们可能选择直接使用的特定功能。TBB 以三种方式提供 C++ 类用于分配:(1)带有 C++ STL std::allocator<T>
所需签名的分配器,(2)STL 容器的内存池支持,以及(3)对齐数组的特定分配器。
带有 std::allocator 签名的分配器
图 7-14 中列出的一组类为可伸缩内存分配器提供了一个 C++ 级别的接口。根据 C++ 标准,TBB 有四个模板类(tbb_allocator, cached_aligned_allocator
、zero_allocator, and scalable_allocator
)支持与std::allocator<T>
相同的签名。根据 C++11 和以前的标准,除了支持<T>
之外,还支持<void>
,这在 C++17 中已被否决,在 C++20 中可能会被删除。这意味着它们可以作为分配例程被 STL 模板使用,比如vector
。所有四个类都模拟了一个分配器概念,它满足 C++ 的所有“分配器要求”,但是具有标准所要求的用于 ISO C++ 容器的额外保证。
可扩展分配器
scalable_allocator
模板以随处理器数量扩展的方式分配和释放内存。用一个scalable_allocator
代替std::allocator
可以提高程序性能。由scalable_allocator
分配的内存应该由scalable_allocator
释放,而不是由std::allocator
释放。
scalable_allocator
分配器模板要求TBBmalloc
库可用。如果库丢失,对scalable_allocator
模板的调用将会失败。相反,如果内存分配器库不可用,其他的分配器(tbb_allocator
、cached_aligned_allocator
或zero_allocator)
会回到 malloc 并释放。
这个类是用#include <tbb/scalable_allocator.h> and is notably
not
定义的,包含在(通常)全包的tbb/tbb.h
中。
tbb _ 分配器
如果可用,tbb_allocator
模板通过TBBmalloc
库分配和释放内存;否则,恢复使用malloc
和free
。cache_alligned_allocator
和zero_allocator
使用tbb_allocator
;因此,它们在malloc
上提供了相同的回退,但是scalable_allocator
没有,因此如果TBBmalloc
库不可用,它们将会失败。该类由#include <tbb/tbb_allocator.h>
定义
零分配器
zero_allocator
分配清零的内存。可以为任何模拟分配器概念的类 A 实例化一个zero_allocator<T,A>
。A 的默认为tbb_allocator
。zero_allocator
将分配请求转发给 A,并在返回之前将分配归零。这个类是用#include <tbb/tbb_allocator.h>
定义的。
缓存对齐分配器
cached_aligned_allocator
模板提供了可伸缩性和防止虚假共享的保护。它通过确保每个分配都在单独的缓存行上完成来解决错误共享。
仅当虚假共享可能是真正的问题时才使用cache_aligned_allocator
(参见图 7-2 )。cache_aligned_allocator
的功能在空间上是有代价的,因为它以多倍于缓存行大小的内存块来分配,即使对于一个小对象也是如此。填充通常为 128 字节。因此,用cache_aligned_allocator
分配许多小对象可能会增加内存使用。
尝试使用tbb_allocator
和cache_aligned_allocator
并测量特定应用的最终性能是一个好主意。
注意,只有当两个对象都被分配了cache_aligned_allocator
时,才能保证防止两个对象之间的错误共享。例如,如果一个对象是由cache_aligned_allocator<T>
分配的,而另一个对象是以其他方式分配的,那么就不能保证防止错误共享,因为cache_aligned_allocator<T>
在高速缓存行边界开始分配,但不一定分配到高速缓存行的末端。如果正在分配数组或结构,因为只有分配的开始是对齐的,所以单个数组或结构元素可能与其他元素一起位于高速缓存线上。图 7-3 显示了一个这样的例子,以及将元素强制到单个缓存行的填充。
这个类是用#include <tbb/cache_alligned_allocator.h>
定义的。
内存池支持:memory_pool_allocator
池分配器是一种非常有效的方法,可以为许多固定大小的对象提供分配。我们的第一个分配器用法很特殊,它要求保留足够的内存来存储大小为P
的T
个对象。此后,当分配器用于提供内存块时,它将偏移量 mod P
返回到分配的内存块中。这比为每个请求分别调用操作符new
要有效得多,因为它避免了为不同大小的分配服务大量请求的通用内存分配器所需的簿记开销。
该类主要用于在 STL 容器中启用内存池。这是我们写这本书时的一个“预览”功能(将来可能会提升为一个常规功能)。使用#define TBB_PREVIEW_MEMORY_POOL 1
启用预览功能。
由tbb::memory_pool_allocator
和tbb:: memory_pool_allocator
提供支持。这些要求
数组分配支持:aligned_space
这个模板类(aligned_space
)占据了足够的内存,并且足够对齐以容纳一个数组T[N]
。元素不由该类构造或销毁;客户端负责初始化或销毁对象。在需要一块固定长度的未初始化内存的场景中,aligned_space
通常用作局部变量或字段。这个类是用#include <tbb/aligned_space.h>
定义的。
有选择地替换新的和删除
开发定制的 new/delete 操作符有很多原因,包括错误检查、调试、优化和使用统计信息收集。
我们可以认为new/delete
是单个对象和对象数组的变体。此外,C++11 定义了其中每一个的抛出、非抛出和放置版本:或者是全局集合(::operator new
、::operator new[]
、::operator delete
和::operator delete[]
,或者是类特定集合(对于类X
,我们有X::operator new
、X::operator new[]
、X::operator delete
和X::operator delete[]
)。最后,C++17 给所有版本的 new 增加了一个可选的对齐参数。
如果我们想要全局替换所有的new
/ delete
操作符,并且没有任何定制需求,我们将使用代理库。这也有取代malloc/free
和相关 C 函数的好处。
出于自定义需要,重载特定于类的运算符而不是全局运算符是最常见的。本节展示了如何替换全局new
/ delete
操作符,作为一个例子,可以根据特定的需求进行定制。我们展示了抛出和非抛出版本,但是我们没有覆盖放置版本,因为它们实际上不分配内存。我们也没有实现带有对齐(C++17)参数的版本。也可以使用相同的概念替换单个类的new
/ delete
操作符,在这种情况下,您可以选择实现放置版本和对齐功能。如果使用代理库,所有这些都由 TBB 处理。
图 7-15 和 7-16 一起展示了一种替换new
和delete
的方法,图 7-17 展示了它们的用法。所有版本的new
和delete
都要立刻更换,相当于四个版本的new
和四个版本的delete
。当然,需要与可扩展内存库链接。
我们的例子选择忽略任何新的处理程序,因为存在线程安全问题,它总是抛出std::bad_alloc()
。基本签名的变体包括附加参数const std::nothrow_t&
,这意味着如果分配失败,该操作符不会抛出异常,但会返回NULL
。这四个非抛出异常操作符可用于 C 运行时库。
我们不需要初始化任务调度器就可以使用内存分配器。我们在这个例子中初始化它,因为它使用了parallel_for
来演示在多个任务中使用内存分配和释放。类似地,内存分配器唯一需要的头文件是tbb/tbb_allocator.h
。
图 7-17。
演示新/删除替换的驱动程序
图 7-16
延续上图,替换删除运算符
图 7-15
新操作员替换示范(tbb_nd.cpp
)
性能调音:一些控制旋钮
TBB 提供了一些关于操作系统分配、大页面支持和内部缓冲区刷新的特殊控制。每一个都是用来微调性能的。
大页面(Windows 上的大页面)用于提高使用大量内存的程序的性能。为了使用巨大的页面,我们需要一个支持的处理器,一个支持的操作系统,然后我们需要做一些事情,这样我们的应用程序就可以利用巨大的页面。幸运的是,大多数系统都有这一切,TBB 包括支持。
什么是巨页?
在大多数情况下,处理器一次在通常称为页面的地方分配内存 4K 字节。虚拟内存系统使用页表将地址映射到实际的内存位置。无需深入研究,只需说明应用程序使用的内存页面越多,就需要越多的页面描述符,并且大量页面描述符的到处乱飞会导致各种各样的性能问题。为了帮助解决这个问题,现代处理器支持比 4K 大得多的额外页面大小(例如,4 MB)。对于使用 2 GB 内存的程序,需要 524,288 个页面描述来描述 2 GB 内存和 4K 页面。使用 4 MB 描述符只需要 512 个页面描述,如果 1 GB 描述符可用,只需要两个。
TBB 支持大页面
要使用具有 TBB 内存分配的大页面,应该通过调用scalable_allocation_mode( TBBMALLOC_USE_HUGE_PAGES,1)
或者通过将TBB_MALLOC_USE_HUGE_PAGES
环境变量设置为1
来显式启用它。当用tbbmalloc_proxy
库替换标准 malloc 例程时,环境变量很有用。
这些提供了调整用于 TBB 可伸缩内存分配器所有用法的算法的方法(不管使用的方法:代理库、C 函数或 C++ 类)。这些函数优先于任何环境变量设置。这些绝对不是随便用的,它们是为自称为“控制狂”的人准备的,并为特定需求提供了优化性能的好方法。当使用这些特性时,我们建议在目标环境中仔细评估对应用程序的性能影响。
当然,这两种方法都假设系统/内核被配置为分配巨大的页面。TBB 内存分配器还支持预分配和透明的巨大页面,这些页面在合适的时候由 Linux 内核自动分配。巨大的页面不是万能的;如果没有很好地考虑它们的使用,它们会对性能产生负面影响。
如图 7-18 所列的功能用#include <tbb/tbb_allocator.h>
定义。
图 7-18
改进 TBB 可伸缩内存分配器行为的方法
scalable _ allocation _ mode(int mode,intptr_t value)
scalable_allocation_mode
函数可以用来调整可伸缩内存分配器的行为。下面两段中描述的参数控制 TBB 分配器的行为。如果操作成功,函数返回TBBMALLOC_OK
,如果模式不是下面小节中描述的模式之一,或者如果值对于给定的模式无效,函数返回TBBMALLOC_INVALID_PARAM
。当所描述的条件适用时,返回值TBBMALLOC_NO_EFFECT
是可能的(参见每个函数的解释)。
TBBMALLOC_USE_HUGE_PAGES
scalable_allocation_mode(TBBMALLOC_USE_HUGE_PAGES,1)
如果操作系统支持,这个函数允许分配器使用巨大的页面;零作为第二个参数禁用它。将 TBB_MALLOC_USE_HUGE_PAGES 环境变量设置为 1 与调用scalable_allocation_mode to
启用该模式具有相同的效果。用scalable_allocation_mode
设置的模式优先于环境变量。如果平台不支持大页面,该函数将返回TBBMALLOC_NO_EFFECT
。
TBBMALLOC_SET_SOFT_HEAP_LIMIT
scalable_allocation_mode(TBBMALLOC_SET_SOFT_HEAP_LIMIT, size)
这个函数为分配器从操作系统中获取的内存量设置了一个size
字节的阈值。超过阈值将促使分配器从其内部缓冲区释放内存;但是,这并不妨碍 TBB 可伸缩内存分配器在需要时请求更多的内存。
int scalable _ allocation _ command(int cmd,void∫param)
scalable_allocation_command
函数可用于命令可伸缩内存分配器执行由第一个参数指定的动作。第二个参数是保留的,必须设置为零。如果操作成功,函数将返回TBBMALLOC_OK
,如果reserved
不等于零,或者cmd
不是定义的命令(TBBMALLOC_CLEAN_ALL_BUFFERS
或TBBMALLOC_CLEAN_THREAD_BUFFERS)
,函数将返回TBBMALLOC_INVALID_PARAM
。返回值TBBMALLOC_NO_EFFECT
是可能的,如下所述。
TBBMALLOC_CLEAN_ALL_BUFFERS
scalable_allocation_command(TBBMALLOC_CLEAN_ALL_BUFFERS, 0)
这个函数清理分配器的内部内存缓冲区,并可能减少内存占用。这可能会导致后续内存分配请求的时间增加。该命令不是为频繁使用而设计的,建议仔细评估性能影响。如果没有缓冲区被释放,该函数将返回TBBMALLOC_NO_EFFECT
。
TBBMALLOC_CLEAN_THREAD_BUFFERS
scalable_allocation_command(TBBMALLOC_CLEAN_THREAD_BUFFERS, 0)
这个函数清理内部内存缓冲区,但只针对调用线程。这可能导致后续内存分配请求的时间增加;建议仔细评估性能影响。如果没有缓冲区被释放,该函数将返回TBBMALLOC_NO_EFFECT
。
摘要
使用可伸缩的内存分配器是任何并行程序中的一个基本元素。性能优势可能非常显著。如果没有可伸缩的内存分配器,由于分配争用、错误共享和其他无用的缓存到缓存的传输,经常会出现严重的性能问题。TBB 可伸缩内存分配(TBBmalloc
)功能包括使用new
以及显式调用malloc
,等等,所有这些都可以直接使用,或者都可以通过 TBB 的代理库功能自动替换。无论我们是否使用 TBB 的任何其他部分,都可以使用 TBB 的可伸缩内存分配;无论使用哪种内存分配器(TBBmalloc, tcmalloc, jemalloc
、malloc
等),都可以使用 TBB 的其余部分。).TBBmalloc
库今天仍然非常流行,并且绝对是可用的最好的可伸缩内存分配器之一。
开放存取本章根据知识共享署名-非商业-非专用 4.0 国际许可协议(http://Creative Commons . org/licenses/by-NC-nd/4.0/)的条款进行许可,该协议允许以任何媒体或格式进行任何非商业使用、共享、分发和复制,只要您适当注明原作者和来源,提供知识共享许可协议的链接,并指出您是否修改了许可材料。根据本许可证,您无权共享从本章或其部分内容派生的改编材料。
本章中的图像或其他第三方材料包含在本章的知识共享许可中,除非在材料的信用额度中另有说明。如果材料不包括在本章的知识共享许可中,并且您的预期使用不被法律法规允许或超出了允许的使用范围,您将需要直接从版权所有者处获得许可。
八、将并行模式映射到 TBB
有人说,历史不会重复,它只是押韵。
可以说软件也是押韵的。虽然我们可能不会一遍又一遍地编写相同的代码,但是在我们解决的问题和编写的代码中会出现一些模式。我们可以借鉴类似的解决方案。
本章着眼于已经被证明在以可伸缩的方式解决问题中有效的模式,我们将它们与如何使用 TBB 实现它们联系起来(图 8-1 )。为了实现可扩展的并行化,我们应该把重点放在数据并行上;数据并行是可扩展并行的最佳总体策略。我们的编码需要鼓励将任何任务细分为多个任务,任务的数量能够随着整个问题的大小而增长;大量的任务支持更好的伸缩性。在本章中我们提倡的模式的最好帮助下,编码提供大量的任务帮助我们实现算法的可伸缩性。
我们可以通过观察别人如何有效地做到这一点来学习“并行思考”。当然,我们可以站在巨人的肩膀上,走得更远。
这一章是关于从并行程序员以前的经验中学习,并且在这个过程中,学习如何更好地使用 TBB。我们将模式视为“并行思考”的灵感和有用工具我们描述模式并不是为了形成一个完美的编程分类法。
并行模式与并行算法
正如我们在第二章中提到的,这本书的评论者建议我们“TBB 并行算法”应该被称为模式而不是算法。这可能是真的,但是为了与 TBB 图书馆多年来使用的术语保持一致,我们在本书和 TBB 文档中将这些特性称为通用并行算法。效果是一样的——它们为我们提供了从那些在我们之前探索过这些模式的最佳解决方案的人的经验中受益的机会——不仅是为了使用它们,而且鼓励我们更喜欢使用这些特定的模式(算法)而不是其他可能的方法,因为它们往往工作得最好(实现更好的可伸缩性)。
图 8-1
表达重要“工作模式”的 TBB 模板
模式对算法、设计等进行分类。
面向对象编程的价值是由四人组(Gamma、Helm、Johnson 和 Vlissides)和他们的标志性工作设计模式 :可重用面向对象软件的元素 (Addison-Wesley)描述的。许多人认为这本书给面向对象编程的世界带来了更多的秩序。他们的书收集了社区的集体智慧,并将其归结为带有名称的简单“模式”,因此人们可以谈论它们。
Mattson、Sanders 和 Massingill (Addison-Wesley)的《并行编程的模式》也从并行编程社区收集了类似的智慧。专家用常用的招数,有自己的语言来讲技巧。考虑到并行模式,程序员可以很快跟上并行编程的速度,就像面向对象的程序员在著名的“四人帮”一书中所做的那样。
并行编程的模式比这本书还长,阅读起来也很密集,但是在作者 Tim Mattson 的帮助下,我们可以总结出这些模式与 TBB 的关系。
Tim 等人提出程序员需要通过四个设计空间来开发一个并行程序:
-
寻找并发性。
对于这个设计空间,我们在我们的问题域内工作,以识别可用的并发性,并将其公开用于算法设计。TBB 通过鼓励我们找到尽可能多的任务来简化这项工作,而不必担心如何将它们映射到硬件线程。我们还提供了当任务足够大时,如何最好地将任务分成两半的信息。利用这些信息,TBB 然后自动重复划分大型任务,以帮助在处理器内核之间平均分配工作。大量的任务导致了我们算法的可扩展性。
-
算法结构。
这个设计空间体现了我们组织并行算法的高级策略。我们需要弄清楚我们想要如何组织我们的工作流程。图 8-1 列出了重要的模式,我们可以参考这些模式来选择最适合我们需求的模式。这些“有效模式”是麦克库尔、罗宾逊和赖因德斯(Elsevier)的结构化并行编程的焦点。
-
支撑结构。
这一步包括将算法策略转化为实际代码的细节。我们考虑如何组织并行程序,以及用于管理共享数据(尤其是可变数据)的技术。这些考虑是至关重要的,并对整个并行编程过程产生影响。TBB 的设计很好地鼓励了正确的抽象层次,所以这个设计空间通过很好地使用 TBB 而得到满足(这是我们希望在本书中教授的)。
-
实施机制。
这个设计空间包括线程管理和同步。线程构建模块处理所有的线程管理,让我们只需担心更高层次的设计任务。当使用 TBB 时,大多数程序员编码避免显式同步编码和调试。TBB 算法(第章第二部分)和流程图(第章第三部分)旨在最小化显式同步。第五章讨论了当我们确实需要时的同步机制,第六章提供了容器和线程本地存储来帮助限制对显式同步的需求。
使用模式语言可以指导创建更好的并行编程环境,并帮助我们充分利用 TBB 来编写并行软件。
有效的模式
有了模式语言的武装,我们应该把它们当作工具。我们强调已被证明对开发最具伸缩性的算法有用的模式。我们知道,实现并行可伸缩性的两个先决条件是良好的数据局部性和避免开销。幸运的是,为了实现这些目标,已经开发了许多好的策略,并且可以使用 TBB 进行访问(参见图 8-1 中的表格)。考虑到需要针对真实机器进行良好的调优,TBB 内部已经提供了一些细节,包括与模式实现相关的问题,比如粒度控制和缓存的良好使用。
在这些方面,TBB 处理实现的细节,因此我们可以在更高的水平上编程。这就是为什么使用 TBB 编写的代码是可移植的,而将特定于机器的调优留在了 TBB 内部。反过来,TBB 通过任务窃取等算法,帮助最小化 TBB 端口所需的调优。将算法策略抽象成语义和实现已经证明在实践中非常有效。这种分离使得分别推理高级算法设计和低级(通常是特定于机器的)细节成为可能。
模式为讨论解决问题的方法提供了一个公共词汇表,并允许重用最佳实践。模式超越了语言、编程模型,甚至计算机体系结构,无论我们使用的编程系统是否明确支持具有特定特性的给定模式,我们都可以使用模式。幸运的是,TBB 被设计成强调经过验证的模式,这些模式导致结构良好的、可维护的和高效的程序。这些模式中的许多实际上也是确定性的(或者可以在确定性模式下运行——参见第十六章),这意味着它们每次执行时都会给出相同的结果。确定性是一个有用的属性,因为它使程序更容易理解、调试、测试和维护。
数据并行性胜出
可扩展并行的最佳总体策略是数据并行。数据并行度的定义各不相同。我们从更广的角度出发,将数据并行定义为随着数据集的增长,或者更一般地说,随着问题规模的增长而增长的任何类型的并行。通常,数据被分割成块,每个块由单独的任务处理。有时候,分裂是平的;其他时候,它是递归的。重要的是,更大的数据集产生更多的任务。
相似还是不同的操作被应用于组块与我们的定义无关。一般来说,无论问题是规则的还是不规则的,都可以应用数据并行。因为数据并行是可扩展并行的最佳策略,所以数据并行的硬件支持通常存在于所有类型的硬件中——CPU、GPU、ASIC 设计和 FPGA 设计。第四章讨论了对 SIMD 的支持,正是为了连接这样的硬件支持。
数据并行的对立面是功能分解(也称为任务并行),这是一种并行运行不同程序功能的方法。在最好的情况下,功能分解通过一个常量因子来提高性能。例如,如果一个程序有函数f
、g
、and h
,并行运行它们最多能使性能提高三倍,实际上则更少。有时,功能分解可以提供满足性能目标所需的额外的并行性,但它不应该是我们的主要策略,因为它没有伸缩性。
图 8-2
嵌套模式:一种组合模式,允许其他模式组合成一个层次结构。嵌套是指模式中的任何任务块都可以用具有相同输入输出配置和依赖关系的模式来替换。
嵌套模式
嵌套(图 8-2 )可能看起来是显而易见和正常的,但在并行编程世界中却不是这样。TBB 让生活变得简单——嵌套工作正常,没有 OpenMP 等其他模型可能存在的严重超额订阅问题。
强调我们从嵌套支持中得到的两个含义:
-
当选择是否应该调用 TBB 模板时,我们不需要知道我们是在“并行区域”还是“串行区域”。因为使用 TBB 只是创建任务,所以我们不必担心线程的超额订阅。
-
我们不需要担心调用一个用 TBB 编写的库,以及控制它是否可能使用并行。
嵌套可以被认为是一种元模式,因为它意味着模式可以分层构成。这对模块化编程很重要。嵌套在串行编程中广泛用于可组合性和信息隐藏,但在并行编程中却是一个挑战。实现嵌套并行的关键是指定可选的而不是强制的并行。与其他模式相比,这是 TBB 擅长的一个领域。
当 TBB 在 2006 年被引进时,筑巢的重要性就被很好地理解了,而且它在整个 TBB 一直得到很好的支持。相比之下,OpenMP API 是在 1997 年引入的,当时我们没有充分预见到嵌套模式对未来机器的重要性。因此,整个 OpenMP 都不支持嵌套模式。这使得 OpenMP 更难用于应用程序世界之外的任何东西,这些应用程序几乎将所有工作都集中在计算密集型循环嵌套中。这些是在 20 世纪 80 年代和 90 年代创建 OpenMP 及其前身时主导我们思维的应用类型。当 TBB 被创造出来时,具有模块化和可组合性的嵌套模式是我们思考的关键(我们认为麻省理工学院的 Cilk 研究工作是对我们的思考产生重大影响的开创性工作——更多关于影响的评论,包括 Cilk,见附录 A)。
图 8-3
映射模式:一个函数应用于一个集合的所有元素,通常产生一个与输入形状相同的新集合。
地图图案
映射模式(图 8-3 )是并行编程可能的最佳模式:将工作划分为统一的独立部分,这些部分并行运行,没有依赖性。这代表了一种被称为尴尬并行的常规并行化。也就是说,在有独立的并行工作要做的情况下,并行性似乎最为明显。当一个算法很好地扩展时,获得高性能没有什么令人尴尬的!这种特性使得 map 模式值得尽可能地使用,因为它允许高效的并行化和高效的矢量化。
一个映射模式包含没有在部件之间共享可变状态;映射函数(独立的工作部分)必须是“纯的”,因为它不能修改共享状态。修改共享(可变)状态会破坏完美的独立性。这可能导致数据竞争的不确定性,并导致不确定的行为,包括可能的应用程序故障。当使用复杂的数据结构时,可能会出现隐藏的共享数据,例如std::share_ptr
,这可能具有共享的含义。
贴图模式的用途包括图像中的伽玛校正和阈值处理、颜色空间转换、蒙特卡罗采样和光线跟踪。使用parallel_for
通过 TBB 高效实现地图(图 8-4 中的例子)。此外,parallel_invoke
可以用于少量的 map 类型并行,但是有限的数量不会提供太多的可伸缩性,除非并行也存在于其他级别(例如,在被调用的函数内部)。
图 8-4
与parallel_for
并行实现的地图模式
工作瓦模式
工作文件模式是一种通用的映射模式,其中每个实例(映射函数)可以生成更多的实例。换句话说,工作可以被添加到“一堆”要做的事情中。例如,这可以用在树的递归搜索中,我们可能希望生成实例来处理树的每个节点的每个子节点。与 map 模式不同,对于 workpile 模式,map 函数的实例总数事先并不知道,工作的结构也不规则。这使得工作文件模式比映射模式更难矢量化(第四章)。使用parallel_do
(第章 2 )与 TBB 一起高效地实现工作堆。
图 8-5
归约模式:子任务产生子结果,这些子结果组合起来形成最终的单一答案。
缩减模式 _ 缩减和扫描)
归约模式(图 8-5 )可以被认为是一个映射操作,其中每个子任务产生一个子结果,我们需要将这些子结果组合起来形成一个最终的单一答案。reduce 模式使用关联的“组合器函数”组合多个子结果。由于组合器函数的结合性,不同的组合顺序是可能的,这既是祸也是福。幸运的是,一个实现可以自由地通过以任何最有效的顺序组合来最大化性能。糟糕的是,如果由于舍入或饱和而导致每次运行的结果都有变化,这将在输出中产生不确定性。组合寻找最大数或寻找所有子结果的布尔 AND 不会遭受这些问题。然而,由于舍入变化,使用浮点数的全局加法将是不确定的。
TBB 为归约操作提供了非确定性(最高性能)和确定性(通常只有轻微的性能损失)。术语“确定性”仅指每次运行中确定的减少顺序。如果组合函数是确定性的,比如布尔 AND,那么parallel_reduce
的非确定性顺序将产生确定性结果。
典型的组合器功能包括加法、乘法、最大值、最小值和布尔运算以及 and、OR 和 XOR。我们可以使用parallel_reduce
(第二章)来实现非确定性归约。我们可以使用parallel_deterministic_reduce
(第十六章)来实现确定性归约。两者都允许我们定义自己的组合函数。
扫描模式(图 8-6 )并行计算前缀(也称为扫描)。与其他缩减一样,如果op
是关联的,这可以并行完成。这在看起来具有内在串行依赖性的场景中很有用。许多人对有一种可扩展的方式来实现这一点感到惊讶。图 8-7 显示了串行代码的示例。并行版本比串行版本需要更多的操作,但它提供了伸缩性。TBB parallel_scan
(第二章)用于执行扫描操作。
图 8-8
Fork-join 模式:允许控制流分叉成多个并行流,稍后再重新连接
图 8-7
执行扫描操作的串行代码
图 8-6
扫描模式:复杂性给出了提供缩放所需的额外操作的可视化。
叉形连接模式
fork-join 模式(图 8-8 )递归地将一个问题细分成子部分,可用于常规和非常规并行化。它对于实现分治策略(有时称为模式本身)或分支绑定策略(有时也称为模式本身)很有用。分叉连接不应与障碍混淆。屏障是跨多个线程的同步构造。在屏障中,每个线程必须等待所有其他线程到达屏障,然后它们中的任何一个才会离开。join 也等待所有线程到达一个公共点,但不同的是,在一个障碍之后,所有线程都继续,但在 join 之后,只有一个线程继续。独立运行一段时间,然后使用障碍进行同步,然后再次独立进行的工作实际上与使用中间有障碍的 map 模式是一样的。这类程序会受到 Amdahl 的法律处罚(详见前言),因为时间是用来等待而不是工作的(序列化)。
我们应该考虑parallel_for
和parallel_reduce
,因为如果我们的需求不是太不规则,它们会自动实现我们需要的功能。TBB 模板parallel_invoke
(章节 2 )、task_group
(章节 10 )、flow_graph (
章节 3 )是实现 fork-join 模式的方法。除了这些直接编码方法,值得注意的是,TBB 实现中的 fork-join 用法和嵌套支持使得无需显式编码就可以获得 fork-join 和嵌套的好处。一个parallel_for
将自动使用优化的 fork-join 实现来帮助跨越可用的并行性,同时保持可组合性,以便嵌套(包括嵌套的parallel_for
循环)和其他形式的并行性可以同时激活。
分治模式
fork-join 模式可以被认为是基本模式,而divide-and-concurve是我们如何分叉和加入的一种策略。这是否是一个独特的模式是一个语义问题,对于我们这里的目的并不重要。
如果一个问题可以被递归地分成更小的子问题,直到达到一个可以串行解决的基本情况,那么分治模式就适用了。分而治之可以描述为划分(分割)一个问题,然后使用 map 模式来计算分割中每个子问题的解决方案。子问题的结果解被组合起来,给出原问题的解。分而治之有助于并行实现,因为只要更多的工人(任务)有利,就可以很容易地细分工作。
当需要各个击破时,parallel_for
和parallel_reduce
实现应该首先考虑的功能。同样,可以使用相同的模板实现分治,这些模板可以作为实现 fork-join 模式的方法(parallel_invoke
、task_group
和flow_graph
)。
分枝限界模式
fork-join 模式可以被认为是基本模式,而分支-绑定是我们如何分叉和连接的一种策略。这是否是一个独特的模式是一个语义问题,对于我们这里的目的并不重要。
分支限界是一种非确定性搜索方法,用于在可能有多个答案时找到一个满意的答案。Branch 指的是使用并发性,bound 指的是以某种方式限制计算——例如,通过使用上限(比如目前为止找到的最佳结果)。“分支定界”这个名字来源于这样一个事实:我们递归地将问题分成几个部分,然后在每个部分中绑定解决方案。相关技术,如 alpha-beta 修剪,也用于人工智能中的状态空间搜索,包括国际象棋和其他游戏的棋步评估。
与许多其他并行算法不同,分支限界可以导致超线性加速。然而,每当有多个可能的匹配时,这种模式是不确定的,因为返回哪个匹配取决于在每个子集上搜索的时间。为了获得超线性加速,需要以有效的方式取消正在进行的任务(参见第十五章)。
搜索问题确实有助于并行实现,因为有许多点需要搜索。然而,因为枚举在计算上太昂贵,所以应该以某种方式协调搜索。一个好的解决方案是使用分支定界策略。我们没有在搜索空间中探索所有可能的点,而是选择重复地将原始问题分成更小的子问题,评估到目前为止子问题的具体特征,根据手头的信息设置约束(界限),并消除不满足约束的子问题。这种消除通常被称为“修剪”这些边界用于“修剪”搜索空间,消除可能被证明不包含最优解的候选解。通过这种策略,可行解空间的大小可以逐渐减小。因此,我们只需要探索一小部分可能的输入组合来找到最优解。
分支限界是一种非确定性方法,也是非确定性有用的一个很好的例子。要进行并行搜索,最简单的方法是划分集合并并行搜索每个子集。考虑这样一种情况,我们只需要一个结果,任何满足搜索条件的数据都是可接受的。在这种情况下,一旦在任何一个并行子集搜索中找到匹配搜索标准的项目,就可以取消其他子集中的搜索。
分支限界还可以用于数学优化,具有一些额外的功能。在数学优化中,给我们一个目标函数、一些约束方程和一个定义域。该函数取决于某些参数。域和约束方程定义了参数的合法值。在给定的域内,优化的目标是找到使目标函数最大化(或最小化)的参数值。
parallel_for
和parallel_reduce
实现了在需要分支绑定时应该首先考虑的功能。同样,可以使用相同的模板实现分治,这些模板可以作为实现 fork-join 模式的方法(parallel_invoke
、task_group
和flow_graph
)。理解 TBB 对取消的支持(见第十五章)在实现分支定界时可能特别有用。
管道模式
管道模式(图 8-9 )很容易被低估。通过嵌套和流水线实现并行的机会是巨大的。管道模式以常规的、不变的数据流连接生产者-消费者关系中的任务。
从概念上讲,管道的所有阶段都是同时活动的,每个阶段都可以维护状态,当数据流经这些阶段时,状态可以更新。这通过流水线操作提供了并行性。此外,由于 TBB 的嵌套支持,每个阶段内部都可以有并行性。TBB parallel_pipeline
(第二章)支撑基础管线。更一般地,一组阶段可以被组装在有向非循环图(网络)中。TBB flow_graph
(第三章)支持管道和广义管道。
图 8-10
基于事件的协调模式:任务以生产者-消费者关系连接,任务之间的交互不规则,并且可能不断变化
图 8-9
管道模式:以常规的不变的生产者-消费者关系连接的任务
基于事件的协调模式(反应流)
基于事件的协调模式(图 8-10 )将生产者-消费者关系中的任务与任务间不规则的、可能变化的交互联系起来。处理异步活动是一个常见的编程挑战。
这种模式很容易被低估,原因与许多人低估管道的可伸缩性相同。通过嵌套和流水线实现并行的机会是巨大的。
我们使用术语“基于事件的协调”,但我们并不试图将其与“参与者”、“反应流”、“异步数据流”或“基于事件的异步”区分开来
这种模式所需的独特的控制流方面导致了 TBB 的flow_graph
(第三章)能力的发展。
异步事件的示例包括来自多个实时数据馈送源(如图像馈送或 Twitter 馈送)的中断,或者用户界面活动(如鼠标事件)。第三章提供了更多关于flow_graph
的细节。
摘要
TBB 鼓励我们思考算法思维和应用程序中存在的模式,并将这些模式映射到 TBB 提供的功能上。TBB 提供了对可伸缩应用程序有效的模式支持,同时提供了处理实现细节的抽象,以保持一切模块化和完全可组合。嵌套的“超级模式”在 TBB 得到了很好的支持,因此 TBB 提供了许多并行编程模型所没有的可组合性。
更多信息
TBB 可以用来实现我们没有讨论的其他模式。我们强调了我们发现的关键模式及其在 TBB 的支持,但是一章很难与整本关于模式的书相提并论。
由麦克库尔、罗宾逊和赖因德斯(Elsevier,2012)撰写的结构化并行编程提供了一个“有效模式”的实践覆盖面这是一本为希望通过实践例子更深入了解模式的程序员准备的书。
Mattson、Sanders 和 Massingill (Addison-Wesley,2004 年)的《并行编程的模式》,对模式及其分类和组件进行了更深入、更学术性的探讨。
开放存取本章根据知识共享署名-非商业-非专用 4.0 国际许可协议(http://Creative Commons . org/licenses/by-NC-nd/4.0/)的条款进行许可,该协议允许以任何媒体或格式进行任何非商业使用、共享、分发和复制,只要您适当注明原作者和来源,提供知识共享许可协议的链接,并指出您是否修改了许可材料。根据本许可证,您无权共享从本章或其部分内容派生的改编材料。
本章中的图像或其他第三方材料包含在本章的知识共享许可中,除非在材料的信用额度中另有说明。如果材料不包括在本章的知识共享许可中,并且您的预期使用不被法律法规允许或超出了允许的使用范围,您将需要直接从版权所有者处获得许可。
九、可组合性的支柱
在这一章中,我们讨论可组合性:它是什么,什么特征使线程构建模块(TBB)成为可组合的线程库,以及如何使用像 TBB 这样的库来创建可伸缩的应用程序。C++ 是一种可组合的语言,TBB 以一种保持可组合性的方式增加了并行性。与 TBB 的可组合性是非常有价值的,因为这意味着我们可以自由地暴露并行的机会,而不用担心系统过载。如果我们不公开并行性,我们就限制了伸缩性。
最终,当我们说 TBB 是一个可组合的并行库时,我们的意思是开发者可以在任何他们想要的地方混合和匹配使用 TBB 的代码。TBB 的这些用法可以是连续的,一个接一个;它们可以嵌套;它们可以是并发的;它们可以都在单个单片应用程序中;它们可以分布在不相交的库中;或者它们可以在同时执行的不同进程中。
并行编程模型通常具有在复杂应用程序中难以管理的限制,这一点可能并不明显。想象一下,如果我们不能在"if
"语句中使用"while
"语句,即使是在我们调用的函数中间接使用。在 TBB 之前,一些并行编程模型也存在同样困难的限制,比如 OpenMP。即使是较新的 OpenCL 标准也缺乏完全的可组合性。
不可组合的并行编程模型最令人沮丧的一面是要求过多的并行性。这太可怕了,而这正是 TBB 所避免的。根据我们的经验,不可组合模型的天真用户经常过度使用并行性——他们的程序会因为内存使用的爆炸而崩溃,或者因为无法承受的同步开销而慢如蜗牛。对这些问题的担心会导致有经验的程序员暴露太少的并行性,从而导致负载不平衡和扩展性差。使用可组合编程模型可以避免担心这种困难的平衡行为。
可组合性使得 TBB 在简单和复杂的应用程序中都非常可靠。可组合性是一种设计理念,它允许我们创建更具可伸缩性的程序,因为我们可以无所畏惧地公开并行性。在第一章中,我们介绍了在许多应用中常见的三层并行蛋糕的概念,如图 9-1 所示。
图 9-1
应用中常见的三个并行层,以及它们如何映射到高级 TBB 并行执行接口
我们在第二章的通用并行算法中介绍了图 9-1 所示的高级接口的基础知识,在第三章介绍了流程图,在第四章介绍了并行 STL。这些高级接口中的每一个都在构建这些并行层中扮演着重要的角色。因为它们都是使用 TBB 任务实现的,而 TBB 是可组合的,所以我们可以安全地将它们组合在一起,形成复杂的、可扩展的应用程序。
什么是可组合性?
不幸的是,可组合性不是编程模型简单的是或否属性。尽管 OpenMP 已经知道嵌套并行的可组合性问题,但是将 OpenMP 标记为不可组合的编程模型是不正确的。如果一个应用程序一个接一个地连续调用 OpenMP 构造,这种连续组合工作得很好。同样,如果说 TBB 是一个完全可组合的编程模型,在任何情况下都能与所有其他并行编程模型很好地协作,那也是言过其实。更准确地说,可组合性是对两个编程模型以特定方式组合时表现如何的度量。
例如,让我们考虑两个并行编程模型:模型A
和模型B
。让我们将T
A
定义为一个内核使用模型A
表示外层并行时的吞吐量,将T
B
定义为同一个内核使用模型 B(不使用模型A
)表示内层并行时的吞吐量。如果编程模型是可组合的,我们会期望使用外部和内部并行的内核的吞吐量为T
AB
>= max(T
A
, T
B
)``T
AB
比max(T
A
, T
)
大多少,这取决于模型相互组合的效率和物理属性
图 9-2 显示了我们可以用来组合软件结构的三种通用组合类型:嵌套执行、并发执行和串行执行。我们说 TBB 是一个可组合的线程库,因为当一个使用 TBB 的并行算法以图 9-2 所示的三种方式之一与其他并行算法组合时,产生的代码执行良好,即T
TBB+Other
>= max(T
TBB
, T
Other
)
。
图 9-2
组成软件结构的方式
在我们讨论导致良好可组合性的 TBB 特性之前,让我们看看每种组合类型,可能出现的问题,以及我们可以预期的性能影响。
嵌套组合
在嵌套组合中,机器在另一个并行算法中执行一个并行算法。嵌套组合的目的几乎总是增加额外的并行性,它甚至可以成倍地增加可以并行执行的工作量,如图 9-3 所示。有效处理嵌套并行是 TBB 设计的主要目标。
图 9-3
嵌套并行会导致可用并行任务的数量呈指数级增长(或者当使用不可组合的库、线程时)
事实上,TBB 库提供的算法在许多情况下依赖于嵌套并行,以便创建可扩展的并行。例如,在第二章中,我们讨论了如何使用嵌套调用 TBB 的parallel_invoke
来创建可伸缩的并行版本的快速排序。线程构建模块库的设计初衷是成为嵌套并行的有效执行者。
与 TBB 相反,在嵌套并行的情况下,其他并行模型的性能可能会非常糟糕。一个具体的例子是 OpenMP API。OpenMP 是一种广泛用于共享内存并行的编程模型,对于单级并行非常有效。然而,对于嵌套并行来说,这是一个众所周知的坏模型,因为强制并行是其定义中不可分割的一部分。在具有多级并行的应用中,每个 OpenMP 并行结构都会创建一个额外的线程组。每个线程分配堆栈空间,也需要由操作系统的线程调度程序进行调度。如果线程数量非常大,应用程序可能会耗尽内存。如果线程数量超过逻辑核心数量,线程必须共享核心。一旦线程数量超过内核数量,由于硬件资源的超额预订,它们往往不会带来什么好处,只会增加开销。
对于 OpenMP 的嵌套并行,最实际的选择通常是完全关闭嵌套并行。事实上,OpenMP API 提供了一个环境变量OMP_NESTED
,用于打开或关闭嵌套并行性。因为 TBB 放宽了顺序语义,使用任务而不是线程来表示并行性,所以它可以灵活地使并行性适应可用的硬件资源。我们可以放心地让嵌套并行在 TBB 上运行——在 TBB 不需要关闭并行的机制!
在本章的后面,我们将讨论 TBB 在执行嵌套并行时非常有效的关键特性,包括它的线程池和工作窃取任务调度器。在第八章中,我们将嵌套视为并行编程中一个非常重要的重复主题(模式)。在第十二章中,我们将讨论一些特性,这些特性允许我们在执行嵌套并行时影响 TBB 库的行为,以创建隔离并改善数据局部性。
并发合成
如图 9-4 ,并发合成是当并行算法的执行在时间上重叠时。并发组合可用于有意增加额外的并行性,或者当两个不相关的应用程序(或同一程序中的构造)在同一系统上并发执行时,它可能偶然出现。并发和并行执行并不总是一回事!如图 9-3 ,并发执行是多个构造在同一时间段内执行,而并行执行是多个构造同时执行。这意味着并行执行是并发执行的一种形式,但并发执行并不总是并行执行。当并发组合被有效地转化为并行执行时,它可以提高性能。
图 9-4
并行与并发执行
图 9-5 中两个循环的并发组合是指循环 1 的并行实现与循环 2 的并行实现同时执行,无论是在两个不同的进程中,还是在同一进程的两个不同线程中。
图 9-5
并发执行的两个循环
当并发执行构造时,仲裁器(像 TBB、操作系统或系统的某种组合这样的运行时库)负责将系统资源分配给不同的构造。如果这两个构造需要同时访问相同的资源,那么对这些资源的访问必须是交叉的。
并发组合的良好性能可能意味着挂钟执行时间与执行运行时间最长的构造的时间一样短,因为所有其他构造都可以与它并行执行(如图 9-4 中的并行执行)。或者,良好的性能可能意味着挂钟执行时间不会长于所有结构的执行时间之和,如果执行需要交错的话(如图 9-4 中的并发执行)。但是没有一个系统是理想的,破坏性和建设性的干扰源使我们不可能获得与这两种情况完全匹配的性能。
首先,仲裁成本增加了。例如,如果仲裁器是 OS 线程调度器,那么这将包括调度算法的开销;抢占式多任务的开销,比如切换线程上下文;以及操作系统的安全和隔离机制的开销。如果仲裁器是像 TBB 这样的用户级库中的任务调度器,那么这个开销就仅限于将任务调度到线程上的开销。如果我们表达非常细粒度的工作,使用调度到一小组线程上的许多任务比直接使用许多线程具有低得多的调度开销,即使任务最终在线程之上执行。
其次,并发使用共享的系统资源(如功能单元、内存和数据缓存)会影响性能。例如,结构的重叠执行会导致数据缓存性能的变化——通常会增加缓存未命中,但在极少数建设性干扰的情况下,甚至可能会减少缓存未命中。
TBB 的线程池及其窃取工作的任务调度程序(将在本章后面讨论)也有助于并发合成,减少仲裁开销,并且在许多情况下导致优化资源使用的任务分配。如果 TBB 的默认行为不令人满意,可以根据需要使用第 11–14 章中描述的功能来减轻资源共享的负面影响。
连续合成
组合两个构造的最后一种方法是顺序执行它们,一个接一个,不要在时间上重叠。这看起来似乎是一种对性能没有影响的微不足道的组合,但(不幸的是)事实并非如此。当我们使用串行组合时,我们通常期望良好的性能意味着两个构造之间没有干扰。
例如,如果我们考虑图 9-6 中的循环,串行组合是先执行循环 3,然后执行循环 4。我们可能会认为,当串行执行时,完成每个并行构造的时间与单独执行同一个构造的时间没有什么不同。如果在使用并行编程模型 A 添加并行性之后单独执行循环 3 所花费的时间是t
3,A
,并且使用并行编程模型 B 单独执行循环 4 所花费的时间是t
4,B
,那么我们将期望连续执行构造的总时间不超过每个构造、t
3,A
+ t
4,B
的次数之和。
图 9-6
一个接一个执行的两个循环
然而,与并发组合一样,可能会出现破坏性和建设性的干扰,并导致实际执行时间偏离这个简单的预期。
在串行组合中,应用程序必须从一个并行结构过渡到下一个。图 9-7 显示了使用相同或不同的并行编程模型时,结构之间的理想和非理想转换。在这两种理想情况下,都没有开销,我们可以立即从一个构造转移到下一个。实际上,在并行执行一个构造之后,通常需要一些时间来清理资源,在执行下一个构造之前,也需要一些时间来准备资源。
图 9-7
在不同构造的执行之间转换
当使用相同的模型时,如图 9-7(b) 所示,运行时库可能会关闭并行运行时,但不得不立即再次启动它。在图 9-7(d) 中,我们看到如果两个不同的模型被用于构造,它们可能不知道彼此,因此第一个构造的关闭和下一个构造的启动,甚至执行可能重叠,也许降低性能。这两种情况都可以进行优化——TBB 在设计时就考虑到了这些转变。
与任何组合一样,性能会受到两个结构之间共享资源的影响。与嵌套或并发组合不同,这些构造不会同时或以交错方式共享资源,但一个构造完成后资源的结束状态仍然会影响下一个构造的性能。例如,在图 9-6 中,我们可以看到循环 3 写入数组b
,然后循环 4 读取数组b
。将循环 3 和 4 中的相同迭代分配给相同的内核可能会提高数据局部性,从而减少缓存未命中。相比之下,将相同的迭代分配给不同的内核会导致不必要的缓存缺失。
使 TBB 成为可组合库的特性
根据设计,线程构建模块(TBB)库是一个可组合库。当它在 10 年前首次推出时,人们认识到,作为一个面向所有开发人员的并行编程库——不仅仅是平面、单一应用程序的开发人员——它必须正面解决可组合性的挑战。使用 TBB 的应用程序通常是模块化的,并利用第三方库,这些库本身可能包含并行性。这些其他并行算法可能有意或无意地与使用 TBB 库的算法组合在一起。此外,应用程序通常在多程序环境中执行,例如在共享服务器或个人笔记本电脑上,其中多个进程同时执行。为了成为一个对所有开发者都有效的并行编程库,TBB 必须要有正确的可组合性。确实如此。
虽然使用 TBB 的特性创建可伸缩的并行应用程序并不需要详细了解它的设计,但我们在这一节中为感兴趣的读者提供了一些细节。如果你足够高兴地相信 TBB 做了正确的事情,并且对如何做不太感兴趣,那么你可以放心地跳过这一节的其余部分。如果没有,请继续阅读,了解为什么 TBB 在可组合性方面如此有效。
TBB 线程池(市场)和任务竞技场
线程构建模块库的两个主要负责其可组合性的特性是其全局线程池(市场)和任务舞台。图 9-8 显示了在一个只有一个主线程的应用程序中,全局线程池和一个默认任务舞台是如何交互的;为简单起见,我们假设目标系统上有P=4
个逻辑核心。图 9-8(a) 显示应用程序有1
个应用程序线程(主线程)和一个用P-1
线程初始化的全局线程工作池。全局线程池中的工作线程执行调度程序(由实心框表示)。最初,全局线程池中的每个线程都处于休眠状态,等待参与并行工作的机会。图 9-8(a) 还显示创建了一个默认任务竞技场。每个使用 TBB 的应用程序线程都有自己的任务舞台,将自己的工作与其他应用程序线程的工作隔离开来。在图 9-8(a) 中,只有一个任务竞技场,因为只有一个应用程序线程。当应用程序线程执行一个 TBB 并行算法时,它会执行一个与该任务领域相关的调度程序,直到算法完成。在等待算法完成时,主线程可以参与执行产生到竞技场中的任务。主线程被示为填充为主线程保留的槽。
图 9-8
在许多应用程序中,只有一个主线程,默认情况下,TBB 库会创建 P-1 个工作线程来参与并行算法的执行
当一个主线程加入一个竞技场并首次产生一个任务时,睡在全局线程池中的工作线程被唤醒并迁移到任务竞技场,如图 9-8(b) 所示。当一个线程加入一个任务领域时,通过填充它的一个槽,它的调度程序可以参与执行由该领域中的其他线程产生的任务,以及产生可以被连接到该领域的其他线程的调度程序看到和窃取的任务。在图 9-8 中,刚好有足够的线程来填充任务竞技场中的槽,因为全局线程池创建了P-1
个线程,而默认任务竞技场有足够的槽来容纳P-1
个线程。通常,这正是我们想要的线程数量,因为主线程加上P-1
工作线程将完全占用机器中的内核,而不会超额订阅它们。一旦任务竞技场被完全占据,任务的产生不会唤醒在全局线程池中等待的额外线程。
图 9-8(c) 显示了当一个工作线程变得空闲,并且在其当前的任务舞台上找不到更多的工作要做时,它返回到全局线程池。在这一点上,工作者可以加入一个需要工作者的不同的任务竞技场,如果一个可用的话,但是在图 9-8 中,只有一个任务竞技场,所以线程将回到睡眠状态。如果稍后有更多的任务可用,已经返回到全局线程池的线程将重新醒来,重新加入任务竞技场,以协助完成额外的工作,如图 9-8(d) 所示。
图 9-8 中概述的场景代表了一个应用程序的常见情况,该应用程序只有一个主线程,没有额外的应用程序线程,并且没有使用 TBB 的高级功能来更改任何默认值。在第 11 和 12 章中,我们将讨论先进的 TBB 特性,这些特性将允许我们创建更复杂的例子,如图 9-9 所示。在这个更复杂的场景中,有许多应用程序线程和几个任务领域。当任务区域的槽多于工作线程时,如图 9-8 所示,工作线程会根据每个任务区域的需求按比例划分。因此,举例来说,一个任务竞技场的开放槽数是另一个任务竞技场的两倍,那么这个任务竞技场将接收大约两倍的工作线程。
图 9-9 强调了关于任务竞技场的其他一些有趣的点。默认情况下,有一个槽是为主线程保留的,如图 9-8 所示。然而,如图 9-9 中右侧的两个任务竞技场所示,可以创建一个任务竞技场(使用我们在后面章节中讨论的高级功能),为主线程保留多个插槽或者根本不为主线程保留插槽。主线程可以填充任何槽,而从全局线程池迁移到 arena 的线程不能填充为主线程保留的槽。
图 9-9
一个更复杂的应用程序,有许多本机线程和任务区
不管我们的应用程序有多复杂,总有一个全局线程池。当 TBB 库初始化时,它将线程分配给全局线程池。在第十一章中,我们将讨论一些特性,这些特性允许我们在初始化时改变分配给全局线程池的线程数量,如果需要的话,甚至可以动态地改变。但是这一组有限的工作线程是 TBB 可组合的一个原因,因为它防止了平台内核的意外超额预订。
每个应用程序线程也有自己的隐式任务舞台。一个线程不能从另一个任务竞技场中的线程窃取任务,所以这很好地隔离了默认情况下不同应用程序线程所做的工作。在第十二章中,我们将讨论应用程序线程如何选择加入其他竞技场——但默认情况下它们有自己的竞技场。
TBB 的设计使得使用 TBB 任务的应用程序和算法在嵌套、并发或串行执行时组合良好。嵌套时,在所有级别生成的 TBB 任务都在同一个竞技场内执行,只使用 TBB 库分配给竞技场的有限工作线程集,防止线程数量呈指数级增长。当由不同的主线程并发运行时,工作线程会在不同的领域之间进行划分。当串行执行时,工作线程可以跨结构重用。
尽管 TBB 库并不直接知道其他并行线程模型所做的选择,但它在全局线程池中分配的有限数量的线程也限制了它对那些其他模型的负担。我们将在本章后面更详细地讨论这一点。
TBB 任务调度员:偷工减料和更多
线程构建模块调度策略通常被描述为工作窃取。这几乎是真的。工作窃取是一种设计用于动态环境和应用程序的策略,在这些环境和应用程序中,任务是动态产生的,并且在多程序系统上执行。当通过工作窃取来分配工作时,工作线程在空闲时会主动寻找新的工作,而不是被动地将工作分配给它们。这种现收现付的工作分配方法非常有效,因为它不会强迫线程停止做有用的工作,这样它们就可以将部分工作分配给其他空闲的线程。偷工减料会将这些开销转移到空闲线程上——反正这些线程也没什么更好的事情可做!工作窃取调度器与工作共享调度器形成对比,后者在任务第一次产生时就预先将任务分配给工作线程。在动态环境中,任务是动态产生的,一些硬件线程可能比其他线程负载更重,工作窃取调度程序更具反应性,从而实现更好的负载平衡和更高的性能。
在 TBB 应用中,线程通过执行附属于特定任务场所的任务分派器来参与执行 TBB 任务。图 9-10 显示了在每个任务竞技场和每个线程任务调度器中维护的一些重要数据结构。
图 9-10
任务竞技场和每线程任务调度程序中的队列
现在,让我们忽略任务竞技场中的共享队列和任务调度程序中的亲和邮箱,只关注任务调度程序中的本地队列 1 。它是用于在 TBB 实现工作窃取调度策略的本地队列。其他数据结构用于实现工作窃取的扩展,我们稍后将回到这些。
在第二章中,我们讨论了由 TBB 库中包含的通用并行算法实现的不同种类的循环。它们中的许多依赖于范围的概念,一组递归可分的值表示循环的迭代空间。这些算法递归地划分循环的范围,使用分割任务来划分范围,直到它们达到一个合适的大小来与循环体配对,以作为体任务来执行。图 9-11 显示了实现循环模式的任务分布示例。顶层任务t
0
表示完整范围的分割,其被递归地分割到叶子,其中循环体被应用到每个给定子范围。使用图 9-11 中所示的分布,每个线程执行主体任务,这些任务在一组连续的迭代中执行。因为附近的迭代经常访问附近的数据,所以这种分布倾向于针对局部性进行优化。因为线程在独立的任务树中执行任务,一旦一个线程得到一个初始子范围,它就可以在那个树上执行,而不需要与其他线程进行太多的交互。
图 9-11
实现循环模式的任务分布
TBB 循环算法是缓存无关算法的例子。具有讽刺意味的是,高速缓存无关算法可能是为了高度优化 CPU 数据高速缓存的使用而设计的——它们只是在不知道高速缓存或高速缓存行大小的细节的情况下这样做。与 TBB 循环算法一样,这些算法通常使用分而治之的方法来实现,该方法递归地将数据集划分为越来越小的片段,这些片段最终可以放入数据缓存中,而不管其大小如何。我们将在第十六章中更详细地介绍缓存无关算法。
TBB 库任务分派器使用它们的本地队列来实现一个调度策略,该策略被优化为与缓存无关的算法一起工作,并创建如图 9-11 所示的分布。这种策略有时被称为深度优先工作,广度优先窃取策略。每当一个线程产生一个新的任务——也就是说,使它可用于它的任务竞技场执行——该任务被放置在其任务调度器的本地队列的头部。稍后,当它完成当前正在处理的任务并需要执行一个新任务时,它会尝试从其本地队列的头端接管工作,接管它最近产生的任务,如图 9-12 所示。然而,如果在任务分派器的本地队列中没有可用的任务,它会通过在其任务领域中随机选择另一个工作线程来寻找非本地工作。我们称所选线程为受害者,因为调度程序正计划从中窃取任务。如果受害者的本地队列不为空,调度程序从受害者线程的本地队列的尾部获取一个任务,如图 9-12 所示,获取该线程最近最少产生的任务。
*
图 9-12
任务调度程序使用的策略,从本地队列的头部获取本地任务,但从受害线程的队列尾部窃取任务
图 9-13 显示了仅使用两个线程执行时,TBB 调度策略如何分配任务的快照。图 9-13 所示的任务是 TBB 循环算法的简化近似。TBB 算法的实现是高度优化的,因此可能会递归地划分一些任务而不产生任务,或者使用调度程序旁路之类的技术(如第十章所述)。图 9-13 中所示的例子假设每个分割和主体任务都产生到任务竞技场中——这对于优化的 TBB 算法来说并不是真正的情况;然而,这个假设在这里用于说明的目的是有用的。
图 9-13
任务如何在两个线程之间分配以及两个任务调度程序为获取任务而采取的操作的快照。注意:TBB 循环模式的实际实现使用调度程序旁路和其他优化来消除一些问题。即便如此,偷窃和执行的顺序也会和这个数字差不多。
在图 9-13 中,线程 1 从根任务开始,最初将范围分成两大块。然后,它沿着任务树的一侧进行深度优先,拆分任务,直到到达叶子,在叶子处将主体应用到最后一个子范围。最初空闲的线程 2 从线程 1 的本地 deque 的尾部偷取,为自己提供线程 1 从原始范围创建的第二大块。图 9-13(a) 是一个时间快照,例如任务t
4
和t
6
还没有被任何线程占用。如果多两个工作线程可用,我们可以很容易地想象得到如图 9-11 所示的分布。在图 9-13(b) 中时间线的末端,线程 1 和线程 2 在其本地队列中仍有任务。当他们弹出下一个任务时,他们会抓住与他们刚刚完成的任务相邻的叶子。
当查看图 9-11 和图 9-13 时,我们不应该忘记显示的分布只是一种可能性。如果每次迭代的工作量是均匀的,并且没有内核超额预订,我们可能会得到所示的相等分布。然而,工作窃取意味着,如果其中一个线程正在过载的内核上执行,那么它窃取的次数将会减少,因此获得的工作也会减少。然后,其他线程将接手这一松弛部分。仅向内核提供静态、均等的迭代划分的编程模型将无法适应这种情况。
正如我们前面提到的,TBB 任务调度程序不仅仅是窃取工作的调度程序。图 9-14 提供了整个任务分派循环的简化伪代码表示。我们可以看到注释为“执行任务”、“接受由该线程产生的任务”和“窃取任务”的行。这些点实现了我们刚刚在这里概述的偷工减料策略,但是我们可以看到在任务分派循环中还有其他交错的动作。
标有“调度程序旁路”的行实现了一种用于避免任务调度开销的优化。如果一个任务确切地知道调用线程接下来应该执行哪个任务,它可以直接返回它,从而避免任务调度的一些开销。作为 TBB 的用户,这可能是我们不需要直接使用的东西,但是你可以在第十章中了解更多。高度优化的 TBB 算法和流程图不使用如图 9-13 所示的简单实现,而是依靠优化,如调度程序旁路,来提供最佳性能。
标记为“take a task with affinity for this thread”的行查看任务调度程序的 affinity 邮箱,以便在任务试图从随机受害者那里窃取工作之前找到它。这个特性用于实现任务到线程的关联,我们将在第十三章中详细描述。
图 9-14 中标有“从竞技场的共享队列中提取任务”的行用于支持排队的任务——在通常的生成机制之外提交给任务竞技场的任务。这些排队的任务用于需要以大致先进先出的顺序进行调度的工作,或者用于最终需要执行但不是结构化算法的一部分的“发射并忘记”任务。任务排队将在第十章中详细介绍。
图 9-14
用于近似 TBB 任务分派循环的伪代码
图 9-14 所示的 TBB 调度程序是一个用户级的非抢占式任务调度程序。OS 线程调度器要复杂得多,因为它不仅需要处理调度算法,还需要处理线程抢占、线程迁移、隔离和安全性。
把所有的放在一起
前面几节描述了允许 TBB 算法和任务在以各种方式组合时高效执行的设计。早些时候,我们还声称 TBB 在与其他并行车型混合使用时表现也很好。利用我们新获得的知识,让我们重新审视一下我们的组合类型,让我们自己相信 TBB 实际上是一个可组合的模型,因为它的设计。
在这个讨论中,我们将与一个假想的不可组合线程库,不可组合运行时(NCR)进行比较。我们虚构的 NCR 包括需要强制并行的并行结构。每个 NCR 构造将需要一组 P 线程,这些线程需要专用于该构造,直到它完成——它们不能被其他并发执行或嵌套的 NCR 构造共享。NCR 还会在第一次使用 NCR 构造时创建线程,但不会在构造结束后让线程休眠——它会保持线程活跃地旋转,耗尽 CPU 周期,以便在遇到另一个 NCR 构造时能够尽快做出响应。类似这样的行为在其他并行编程模型中并不少见。OpenMP 并行区域确实具有强制并行性,当环境变量 OMP 嵌套设置为“真”时,这会导致大麻烦英特尔 OpenMP 运行时库还提供了一个选项,通过将环境变量OMP_WAIT_POLICY
设置为“活动”来保持工作线程在区域之间积极旋转为了公平起见,我们应该明确指出,英特尔 OpenMP 运行时默认为OMP_NESTED=false
和OMP_WAIT_POLICY=passive
,因此这些不可组合的行为不是默认行为。但是作为比较,我们用 NCR 作为稻草人来代表一个非常糟糕的,不可组合的模型。
现在,让我们看看 TBB 与自己和 NCR 的关系有多好。作为性能的代表,我们将关注超额预订,因为系统超额预订越多,它可能会产生越多的调度和破坏性共享开销。图 9-15 显示了我们的两个模型如何嵌套在一起。当 TBB 算法嵌套在 TBB 算法中时,所有生成的任务将在同一个舞台上执行,并共享P
线程。然而,NCR 显示了线程的爆炸,因为每个嵌套的构造都需要组装自己的P
线程团队,最终甚至需要P
2
线程来实现两级深度嵌套。
图 9-15
用于嵌套在 TBB 的 TBB 和嵌套在 NCR 的不可组合运行时(NCR)的线程数
图 9-16 显示了当我们组合模型时会发生什么。有多少线程同时执行 TBB 算法并不重要——当 TBB 嵌套在 NCR 内部时,TBB 工作线程的数量将保持在P-1!
的上限,因此我们最多只使用2P-1
线程:P
来自 NCR 的线程,它们将在嵌套的 TBB 算法中充当主线程,以及P-1
TBB 工作线程。然而,如果 NCR 构造嵌套在 TBB 内部,那么每个执行 NCR 构造的 TBB 任务将需要组装一组 P 线程。其中一个线程可能是执行外部 TBB 任务的线程,但是其他的P-1
线程将需要由 NCR 库创建或从其获得。因此,我们以 TBB 的P
线程结束,每个线程并行执行,每个线程使用一个额外的P-1
线程,总共有P
2
个线程。我们可以从图 9-15 和 9-16 中看到,当 TBB 嵌套在一个表现很差的模型中时,它表现良好——不像 NCR 这样的不可组合模型。
图 9-16
当 TBB 和不可组合的运行时(NCR)相互嵌套时
当我们考虑并发执行时,我们需要考虑单进程并发(当并行算法由同一进程中的不同线程并发执行时)和多进程并发。TBB 库为每个进程提供了一个全局线程池——但是不在进程间共享线程池。图 9-17 显示了单进程情况下不同并发执行组合使用的线程数量。当 TBB 在两个线程中与自己并发执行时,每个线程都有自己的隐式任务竞技场,但是这些竞技场共享P-1
工作线程;因此,线程总数为P+1
。NCR 在每个构造中使用一组P
线程,所以它使用2P
线程。同样,由于 TBB 和 NCR 不共享线程池,当在单个进程中并发执行时,它们将使用2P
线程。
图 9-17
用于在单个进程中并发执行 TBB 算法和不可组合运行时(NCR)构造的线程数
图 9-18 显示了多进程情况下不同并发执行组合使用的线程数量。由于 TBB 为每个进程创建了一个全局线程池,在这种情况下,它不再比 NCR 有优势。在这三种情况下,都使用了2P
线程。
图 9-18
用于在两个不同的进程中同时执行 TBB 构造和 NCR 构造的线程数
最后,让我们考虑串行合成的情况,当一个算法或构造被一个接一个地执行时。TBB 和 NCR 都将与他们自己的图书馆的其他用途很好地串联起来。如果延迟很短,TBB 线程将仍然在任务舞台上,因为一旦它们用完工作,它们会在很短的时间内积极地寻找工作。如果 TBB 算法之间的延迟很长,TBB 工作线程将返回到全局线程池,并在新工作可用时迁移回任务区。这种迁移的开销非常小,但是不可忽略。即便如此,通常负面影响会非常低。我们假设的不可组合运行时(NCR)从不休眠,所以它总是准备好执行下一个构造——不管延迟多长时间。从可组合性的角度来看,更有趣的情况是当我们将 NCR 和 TBB 组合在一起时,如图 9-17 所示。在一个算法结束后,TBB 很快让它的线程进入睡眠状态,因此它不会对后面的 NCR 构造产生负面影响。相比之下,反应异常灵敏的 NCR 库将保持其线程活跃,因此遵循 NCR 构造的 TBB 算法将被迫与这些旋转的线程争夺处理器资源。TBB 显然是更好的公民,因为它的设计考虑了与其他并行模型的串行可组合性。
图 9-19
用于连续执行 TBB 构造和使用强制并行的构造的线程数
图 9-15 至 9-19 表明,TBB 自身的组合性很好,由于其可组合设计,其对其他并行车型的负面影响有限。TBB 算法能有效地与其他 TBB 算法相结合——但总的来说也是好公民。
展望未来
在后面的章节中,我们将讨论一些扩展本章主题的话题。
控制线程的数量
在第十一章中,我们描述了如何使用task_scheduler_init
、task_arena
和global_control
类来改变全局线程池中的线程数量,并控制分配给任务区域的插槽数量。通常,TBB 使用的默认值是正确的选择,但是如果需要,我们可以更改这些默认值。
工作隔离
在这一章中,我们看到了每个应用程序线程在默认情况下都有自己的隐式任务舞台,将自己的工作与其他应用程序线程的工作隔离开来。在第十二章中,我们讨论了函数this_task_arena::isolate
,它可以用在为了正确性需要工作隔离的不常见情况下。我们还将讨论类task_arena
,它用于创建显式的任务舞台,可用于出于性能原因隔离工作。
任务到线程和线程到内核的亲和性
在图 9-10 中,我们看到每个任务分派器不仅有一个本地的 deque,还有一个亲和邮箱。我们还在图 9-14 中看到,当一个线程在其本地队列中没有剩余工作时,它会在尝试随机窃取工作之前检查这个相似性邮箱。在第十三章中,我们将讨论如何通过使用 TBB 任务所揭示的底层特性来创建任务到线程的关联和线程到内核的关联。在第十六章中,我们将讨论高级 TBB 算法利用数据局部性所使用的范围和分割器等特性。
任务优先级
在第十四章中,我们将讨论任务优先级。默认情况下,TBB 任务分派器将所有任务视为同等重要,并且只是试图尽可能快地执行任务,而不偏袒任何特定的任务。然而,TBB 库允许开发人员为任务分配低、中、高优先级。在第十四章中,我们将讨论如何使用这些优先级以及它们对调度的影响。
摘要
在这一章中,我们强调了可组合性的重要性,并强调如果我们使用 TBB 作为我们的并行编程模型,我们会自动得到它。本章开始时,我们讨论了并行结构相互组合的不同方式,以及每种组合方式所产生的问题。然后,我们描述了 TBB 库的设计,以及这种设计如何导致可组合并行。最后,我们回顾了不同的组合类型,并将 TBB 与一个假想的不可组合运行时(NCR)进行了比较。我们看到,TBB 不仅自身表现良好,而且在与其他并行模式结合时也是一个好公民。
更多信息
Cilk 是一个并行模型和平台,它是最初的 TBB 调度器的主要灵感之一。它提供了工作窃取调度程序的空间高效实现,如
- 罗伯特·d·布卢莫菲和查尔斯·e·莱塞尔森。1993.多线程计算的空间高效调度。《第 25 届 ACM 计算理论年会论文集》(STOC '93)。美国纽约州纽约市 ACM,362–371。
TBB 提供了使用在线程上执行的任务实现的通用算法。通过使用 TBB,开发人员可以使用这些高级算法,而不是直接使用低级线程。有关为什么应该避免直接使用线程作为编程模型的一般性讨论,请参见
- 爱德华·a·李,“线程的问题。”计算机,39,5(2006 年 5 月),33–42。
在某些方面,我们在本章中使用 OpenMP API 作为一个 strawman 不可组合的模型。事实上,OpenMP 是一种非常有效的编程模型,拥有广泛的用户基础,在 HPC 应用中尤其有效。有关 OpenMP 的更多信息,请访问
开放存取本章根据知识共享署名-非商业-非专用 4.0 国际许可协议(http://Creative Commons . org/licenses/by-NC-nd/4.0/)的条款进行许可,该协议允许以任何媒体或格式进行任何非商业使用、共享、分发和复制,只要您适当注明原作者和来源,提供知识共享许可协议的链接,并指出您是否修改了许可材料。根据本许可证,您无权共享从本章或其部分内容派生的改编材料。
本章中的图像或其他第三方材料包含在该章的知识共享许可中,除非该材料的信用额度中另有说明。如果材料未包含在本章的知识共享许可中,并且您的预期用途不被法定法规允许或超出了允许的用途,您将需要直接从版权所有者处获得许可。
*十、使用任务创建自己的算法
我们最喜欢 TBB 的一点是它的“多分辨率”特性。在并行编程模型的上下文中,多分辨率意味着我们可以在不同的抽象层次中进行选择来编码我们的算法。在 TBB,我们有高级模板,如parallel_for
或pipeline
(见第二章),当我们的算法适合这些特定模式时,就可以使用这些模板。但是如果我们的算法没有那么简单呢?或者,如果可用的高级抽象没有挤出我们的并行硬件的最后一滴性能呢?我们应该放弃并继续被编程模型的高级特性所束缚吗?当然不是!应该有一种更接近硬件的能力,一种从头开始构建我们自己的模板的方法,以及一种使用编程模型的底层和更多可调特性来彻底优化我们的实现的方法。在 TBB,这种能力是存在的。在这一章中,我们将关注 TBB 最强大的底层功能之一,任务编程接口。正如我们在整本书中所说的,任务是 TBB 的核心,任务是用来构建高层模板(如parallel_for
和pipeline
)的构件。但是,没有什么可以阻止我们进入这些更深的水域,开始用任务直接编码我们的算法,构建我们自己的高级模板以供将来在任务上使用,或者如我们在下一章中所述,通过微调任务的执行方式来全面优化我们的实现。本质上,这就是你通过阅读本章和后面的章节将学到的东西。享受深潜吧!
一个连续的例子:序列
基于任务的 TBB 实现特别适合于这样的算法,在这种算法中,一个问题可以按照树状分解递归地分成更小的子问题。诸如此类的问题还有很多。分治和分支定界并行模式(第八章)就是这类算法的例子。如果问题足够大,它通常可以在并行架构上很好地扩展,因为很容易将其分成足够多的任务,以充分利用硬件并避免负载不平衡。
为了本章的目的,我们选择了一个最简单的问题,它可以按照一个树状的方法来实现。这个问题被称为斐波那契数列,它包括计算从 0 和 1 开始的整数序列,然后,序列中的每个数字都是前面两个数字的和:
0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, ...
数学上,数列中的nth
、F
、、n
、,可以递归计算为
Fn= Fn-1+Fn-2
用初始值F
0
=0``F
1
=1
。有几种算法可以计算F
n
,但是为了说明 TBB 任务是如何工作的,我们选择了图 10-1 中所示的算法,尽管它不是最有效的。
图 10-1
递归实现 的运算 F
n
Fibonacci 数计算是展示递归的经典计算机科学示例,但也是简单算法效率低下的经典示例。更有效的方法是计算
取左上角的元素。矩阵的幂运算可以通过重复平方快速完成。但是,我们将在这一节继续讨论,并使用经典的递归例子进行教学。
图 10-1 中呈现的代码显然类似于计算F
n
= F
n-1
+ F
n-2
的递归方程。虽然这可能很容易理解,但我们在图 10-2 中进一步阐明了这一点,其中我们描述了调用fib(4)
时的递归调用树。
图 10-2
fib 的递归调用树(4)
图 10-1 的串行代码开头的if (n<2)
行迎合了所谓的基本情况,这在递归代码中总是需要的,以避免无限递归,这很好,因为我们不想用核武器攻击堆栈,不是吗?
我们将使用不同的基于任务的方法对这第一个顺序实现进行并行化,从简单到更精细和优化的版本。我们从这些例子中学到的经验可以在其他树状或递归算法中模仿,我们展示的优化也可以在类似的情况下充分利用我们的并行架构。
高层方法:parallel_invoke
在第二章中,我们已经展示了一个高级类,它可以满足我们生成并行任务的需求:parallel_invoke
。依靠这个类,我们可以实现 Fibonacci 算法的第一个并行实现,如图 10-3 所示。
图 10-3
并行实现 的斐波那契利用 parallel_invoke
parallel_invoke
成员函数递归产生parallel_fib(n-1)
和parallel_fib(n-2)
,返回堆栈变量x
和y
中的结果,这两个变量由两个 lambdas 中的引用捕获。当这两个任务完成时,调用者任务简单地返回x+y
的和。实现的递归性质保持调用并行任务,直到在n<2
时达到基本情况。这意味着 TBB 将创建一个任务来计算分别返回1
和0
的parallel_fib(1)
和parallel_fib(0)
。正如我们在整本书中所说的,我们希望向架构展示足够的并行性,从而创建足够多的任务,但同时任务还必须具有最小的粒度(>1
微秒,正如我们在第 16 和 17 章中讨论的那样),以便任务创建开销得到回报。如图 10-4 所示,这种折衷通常在这种算法中使用一个cutoff
参数来实现。
图 10-4
parallel_invoke
实现的截断版本
想法是修改基本情况,以便当n
不够大(n<cutoff
)时,我们停止创建更多的任务,在这种情况下,我们求助于串行执行。计算一个合适的cutoff
值需要一些实验,所以建议编写我们的代码,使cutoff
可以作为输入参数,以方便搜索合适的值。例如,在我们的测试床上,fib(30)
只需要大约 1 毫秒,所以这是一个足够细粒度的任务,可以阻止进一步的分裂。因此,设置cutoff=30
是有意义的,这导致为接收n=29
和n=28
的任务调用代码的串行版本,如图 10-5 所示。
图 10-5
为了节省空间,图中调用parallel_fib(32) – ParF(32)
后的调用树——fib()
是串行实现的基础用例
如果在看了图 10-5 后,你认为在三个不同的任务中计算fib(29
和在另外两个任务中计算fib(28)
是愚蠢的,你是对的,这是愚蠢的!作为免责声明,我们已经说过这不是最佳的实现,而是一个服务于我们教育兴趣的常用递归示例。一个明显的优化是以这样一种方式实现递归,使得已经计算过的斐波纳契数不再被重新计算,从而实现最优的O(log n)
复杂度,但这不是我们今天的目标。
看完图 10-4 后,你可能会想,为什么我们要再次重温在第 2 中已经讨论过的parallel_invoke
。我们真的到达了本书的第二部分,更高级的部分了吗?没错。嗯……我们可能需要的高级功能、低级旋钮和我们喜欢的优化机会在哪里???好吧,让我们开始潜入更深的水域!!!
较低者中的最高者:task_group
如果我们可以在没有一些任务旋钮和优化特性的情况下生存,那么task_group
类可以很好地为我们服务。如果你愿意的话,这是一个更高级、更容易使用的类,一个中级抽象。图 10-6 给出了依赖于task_group
的斐波纳契码的一种可能的重新实现。
图 10-6
基于task_group
的并行斐波那契
显然,这只是实现图 10-4 中我们使用parallel_invoke
的代码的一种更冗长的方式。然而,我们想强调的是,与parallel_invoke
选项不同,现在我们有了一组任务的句柄g
,正如我们将在后面讨论的,这实现了一些额外的可能性,如任务取消。此外,通过显式调用成员函数g.run()
和g.wait()
,我们产生了新的任务,并等待它们在两个不同的程序点完成计算,而parallel_invoke
函数在任务产生后有一个隐式的任务屏障。首先,run()
和wait()
之间的这种分离将允许调用者线程在产生一些任务和在阻塞调用wait()
中等待它们之间进行一些计算。此外,该类还提供了其他有趣的成员函数,在某些情况下会派上用场:
-
void run_and_wait( const Func& f )
,相当于{run(f); wait();}
,但保证f
运行在当前线程上。我们将在后面(在“低级任务接口:第二部分——任务延续”一节中)看到,有一个绕过 TBB 调度程序的简便技巧。如果我们第一次调用run(f)
,我们基本上生成了一个任务,该任务在工作线程本地队列中排队。当调用wait()
时,我们调用调度器,如果没有其他人在此期间窃取了刚刚入队的任务,那么调度器会将它出队。run_and_wait
的目的有两个:首先,我们避免了入队-调度-出队步骤的开销,其次,我们避免了任务在队列中时可能发生的潜在窃取。 -
void cancel()
,取消此task_group
中的所有任务。也许计算是由一个用户界面触发的,这个用户界面也包括一个“取消”按钮。如果用户现在按下这个按钮,就有办法停止计算。在第十五章中,我们将进一步阐述取消和异常处理。 -
task_group_status wait()
,返回任务组的最终状态。返回值可以是:complete
(组内所有任务都已完成);canceled
(task_group
收到取消请求);not_completed
(组内任务未全部完成)。
注意,在图 10-6 的并行实现中,每个对parallel_fib
的调用都会创建一个新的task_group
,因此可以取消一个分支而不影响其他分支,我们将在第十五章中看到。拥有一个task_group
也带来了一个额外的缺点:在一个组中创建太多的任务会导致任务创建的序列化和随之而来的可伸缩性的损失。例如,我们想写一段这样的代码:
正如我们所见,n
任务将由同一个线程一个接一个地产生。其他工作线程将被迫窃取执行g.run()
的线程创建的每个任务。这肯定会降低性能,尤其是如果foo()
是一个细粒度的任务,并且工作线程的数量nth
很高的话。推荐的替代方案是图 10-6 中使用的方案,其中执行了任务的递归部署。在这种方法中,工作线程在计算开始时偷取,理想情况下,在 log 2
(nth)
步骤中,所有nth
工作线程都在各自的任务中工作,这些任务依次将更多的任务放入它们的本地队列中。例如,对于nth=4
,第一个线程A
产生了两个任务,并开始处理其中一个任务,而线程B
窃取了另一个任务。现在,线程A
和B
各自产生两个任务(总共四个),并在其中两个中开始工作,但是另外两个被线程C
和D
窃取。从现在开始,所有四个线程都在工作,并在它们的本地队列中加入更多的任务,只有当它们用完本地任务时才再次窃取。
当心!风险自担:低级任务界面
task 类有很多特性,这意味着也有很多出错的方法。如果所需的并行模式是一个常见的模式,那么肯定有一个已经可用的高级模板,由聪明的开发人员在任务接口之上实现和优化。在大多数情况下,推荐使用这种高级算法。因此,本章其余部分的目的是服务于两个目标。首先,如果 TBB 已经提供的并行算法或高级模板不适合您的需求,它为您提供了开发自己的基于任务的并行算法或高级模板的方法。第二个是揭示 TBB 机制的底层细节,这样你就可以理解一些优化和技巧,这些将在以后的章节中提到。例如,后面的章节将会引用这一章来解释parallel_pipeline
和流图由于调度绕过技术而更好地利用局部性的方式。在这里,我们解释这项技术是如何工作的,为什么它是有益的。
低级任务接口:第一部分——任务阻塞
TBB 任务类有大量的特性和旋钮来微调我们基于任务的实现的行为。缓慢但肯定的是,我们将引入可用的不同成员函数,逐渐增加我们的 Fibonacci 实现的复杂性。首先,图 10-7 和 10-8 显示了使用低级任务实现斐波那契算法所需的代码。这是我们的基线,使用任务阻塞风格,将在后续版本中优化。
图 10-7
parallel_fib
使用任务类重新实现
图 10-7 的代码包括以下不同的步骤:
图 10-8
图 10-7 中使用的FibTask
类的定义
-
为任务分配空间。任务必须由特殊的成员函数来分配,以便在任务完成时可以有效地回收空间。分配是由一个特殊的重载成员函数
new
和task::allocate_root
完成的。名称中的_root
后缀表示创建的任务没有父任务。它是任务树的根。 -
用构造器
FibTask{n,&sum}
构造任务(任务定义如下图所示),由new
调用。当任务在步骤 3 中运行时,它计算出nth
斐波纳契数,并将其存储到sum
中。 -
使用
task::spawn_root_and_wait
运行任务直至完成。
真正的工作是在图 10-8 中定义的类FibTask
内完成的。与fib
和parallel_fib
之前的两个并行实现相比,这是一段相对较大的代码。我们被告知,这是一个较低层次的实现,因此它不像一个高层次的抽象那样高效和友好。为了弥补额外的负担,我们将在后面看到这个类是如何让我们在引擎盖下动手调整行为和性能的。
像 TBB 调度的所有任务一样,FibTask
是从tbb::task
类派生出来的。字段n
和sum
分别保存输入值和指向输出的指针。这些都是用传递给构造器FibTask(long n_, long ∗sum_)
的参数初始化的。
成员函数execute
执行实际的计算。每个任务都必须提供一个覆盖纯虚拟成员函数tbb::task::execute
的execute
定义。定义应该完成任务的工作,并返回nullptr
或指向下一个要运行的任务的指针,如图 9-14 所示。在这个简单的例子中,它返回nullptr
。
成员函数FibTask::execute()
执行以下操作:
-
检查
n<cutoff
是否正确,并在这种情况下采用顺序版本。 -
否则,执行 else 分支。代码创建并运行两个子任务,分别计算
F
n-1
和F
n-2
。这里,继承的成员函数allocate_child()
用于为任务分配空间。记住,顶层例程parallel_fib
使用allocate_root()
为任务分配空间。不同之处在于,这里的任务是创建子任务。这种关系由分配方法的选择来表示。附录 B 图 B-76 中列出了不同的分配方法。 -
调用
set_ref_count(3)
。数字 3 代表两个孩子和成员函数spawn_and_wait_for_all
所需的一个额外的隐式引用。这个set_ref_count
成员函数初始化每个 TBB 任务的ref_count
属性。每次子节点结束计算,它都会减少其父节点的ref_count
属性。如果任务使用wait_for_all
在子任务完成后恢复,确保在产生k
子任务之前调用set_reference_count(k+1)
。否则会导致未定义的行为。该库的调试版本通常会检测并报告这种类型的错误。 -
产生两个子任务。生成一个任务向调度程序表明,它可以随时运行该任务,可能与执行其他任务并行。由
tbb::task::spawn(b)
成员函数进行的第一次派生会立即返回,而不会等待子任务开始执行。第二次产卵,由成员函数tbb::task::spawn_and_wait_for_all(a)
完成,相当于tbb::task::spawn(a); tbb::task::wait_for_all()
。最后一个成员函数使父任务等待所有当前分配的子任务完成。出于这个原因,我们说这种实现遵循了我们所说的任务阻塞风格。 -
在两个子任务完成之后,父任务的
ref_count
属性已经减少了两次,现在它的值是 1。这导致父任务在spawn_and_wait_for_all(a)
调用后立即恢复,因此它计算x+y
并将其存储在∗sum
中。
在图 10-9 中,我们展示了在设置了cutoff=7
的root_task FibTask(8, &sum)
生成时,该任务的创建和执行。假设单个线程执行所有任务,并简化堆栈的使用方式,在图 10-9 中,我们有一个简化的计算表示。当parallel_fib(8)
被调用时,变量sum
被存储在堆栈中,根任务被分配在堆上,用FibTask(8, &sum)
构造。这个根任务由运行被覆盖的成员函数execute()
的工作线程执行。在这个成员函数中,声明了两个堆栈变量x
和y
,两个新的子任务a
和b
被分配到工作线程的本地队列中。在这两个任务的构造器中,我们传递了FibTask(7, &x)
和FibTask(6, &y)
,这意味着新创建的任务的变量成员sum
将分别指向FibTask(8)
栈变量x
和y
。
图 10-9
带有cutoff=7
的parallel_fib(8)
的递归调用树
成员函数execute()
继续将任务的ref_count
设置为3
,首先生成b
,然后生成a
,并等待两者。此时,根任务被挂起,直到它没有未决的子任务。记住这是任务阻塞风格。工作线程返回到调度器,在那里它将首先使任务a
出队(因为它是最后入队的)。这个任务a (FibTask(7,&x))
将递归地重复相同的过程,在堆栈上分配一个新的x
和y
并生成FibTask(5,&x)
和FibTask(6,&y)
后挂起自己。从cutoff=7
开始,这两个新任务将求助于基础用例,分别调用fib(5)
和fib(6)
。FibTask(6,&x)
先出列,将8
写入∗sum
(其中sum
指向FibTask(7)
栈中的x
),返回nullptr
。然后,FibTask(6,&x)
被销毁,但是在这个过程中,父任务(FibTask(7,&x))
的ref_cont
变量首先被递减。然后,工作线程让将 5 写入∗sum
(现在是堆栈中y
的别名)的FibTask(5,&y)
出列,并返回nullptr
。这导致ref_count
达到值1
,唤醒刚刚要加5+8
的父线程FibTask(7,&x)
,将其写入∗sum
(FibTask(8)
中x
的别名)堆栈,并返回nullptr
。这将根任务的ref_count
减少到 2。接下来,工作线程让调用fib(6)
的FibTask(6,&y)
出列,在堆栈上写y=8
,返回,然后终止。这最终使根任务没有子任务(ref_count=1)
,因此它可以在spawn_and_wait_for_all()
成员函数之后继续执行,添加8+13
,写入∗sum
(sum 在parallel_fib
堆栈中的别名),然后销毁。如果你在读完所有这些过程的描述后感到筋疲力尽,那么我们也一样,但是还有更多,所以再坚持一秒钟。现在,假设有不止一个工作线程。每一个都会有自己的栈,争着抢任务。结果21
将是相同的,本质上,相同的任务将被执行,尽管现在我们不知道哪个线程将负责每个任务。我们所知道的是,如果问题大小和任务数量足够大,并且如果cutoff
被明智地设置,那么这个并行代码将比顺序代码运行得更快。
注意
正如我们已经看到的,TBB 偷工减料调度程序评估一个任务图。该图是有向图,其中每个节点是一个任务。每个任务指向它的父任务,也称为后继任务,是等待它完成的另一个任务。如果一个任务没有父/后继,它的父引用指向nullptr
。方法tbb::task::parent()
给予你对后继指针的只读访问。每个任务都有一个ref_count
,它说明了以它为后继的任务的数量(即,在它被触发执行之前,父任务必须等待的子任务的数量)。
被大肆吹嘘的旋钮和调谐可能性在哪里?的确,我们刚刚讨论的基于底层任务的代码与我们已经用parallel_invoke
和task_group
类实现的代码做得差不多,但是编程成本更高。那么,物有所值的优势在哪里?task 类有更多的成员函数,我们将很快介绍,本节讨论的实现只是构建更优化版本的基础。坚持和我们在一起,继续阅读。
低级任务接口:第二部分——任务延续
如果任务的主体需要许多局部变量,我们刚才介绍的任务阻塞风格可能会造成问题。这些变量放在堆栈中,直到任务被销毁。但是直到它的所有子任务都完成了,任务才被销毁。如果问题非常大,并且很难在不限制可用并行性的情况下找到一个临界值,那么这将是一个潜在的障碍。当面对用于通过遵循基于树的策略明智地遍历搜索空间来找到最优值的分支和界限问题时,这可能发生。在有些情况下,树可能非常大,不平衡(一些树枝比其他树枝更深),树的深度未知。对这些问题使用阻塞方式很容易导致任务数量的激增和堆栈空间的过度使用。
阻塞风格的另一个微妙的不便是由于在父任务中遇到wait_for_all()
调用的工作线程的管理。浪费这个工作线程等待子任务完成是没有意义的,所以我们委托它执行其他任务。这意味着当父任务准备好再次运行时,负责处理它的原始工作线程可能会因其他任务而分心,无法立即响应。
注意
延续,延续,延续!!!《TBB》的作者和其他并行专家喜欢鼓励延续式编程。为什么呢???事实证明,使用它可以区分相对容易编写的工作程序和因堆栈溢出而崩溃的程序。更糟糕的是,除了使用延续之外,解决这种崩溃的代码可能很难理解,并给并行编程带来坏名声。幸运的是,TBB 被设计成使用延续,并鼓励我们默认使用延续。流程图(第 3 和 17 章)鼓励使用continue_node
(以及其他具有调度程序旁路潜力的节点)。作为一名并行程序员,延续(和任务回收,我们接下来将讨论)的力量是值得了解的——您绝不会希望让一个任务再次等待(浪费宝贵的资源)!
为了避免这个问题,我们可以采用一种不同的编码风格,称为延续传递。图 10-10 显示了我们称之为延续任务的新任务的定义,图 10-11 在方框中强调了FibTask
中实现延续传递风格所需的更改。
图 10-10
斐波那契示例的延续任务FibCont
延续任务FibCont
也有一个execute()
成员函数,但是现在它只包含子任务完成后必须完成的代码。对于我们的斐波那契示例,在子元素完成之后,我们只需要添加它们带来并返回的结果,这是图 10-8 代码中spawn_and_wait_for_all(a)
之后仅有的两行代码。continuation 任务声明了三个成员变量:一个指向最终变量sum
的指针和两个子变量x
和y
的部分和。构造器FibCont(long∗ sum)
充分初始化指针。现在我们必须修改我们的FibTask
类来正确地创建和初始化延续任务FibCont
。
图 10-11
遵循并行斐波那契的连续传递风格
在图 10-11 中,除了不变的基本情况,我们在代码的 else 部分发现现在,x
和y
私有变量不再声明,已经被注释掉。然而,现在有了一个新的任务FibCont&
类型的c
。这个任务是使用类似于allocate_child()
的allocate_continuation()
函数分配的,除了它将调用任务(this)
的父引用传递给c
,并将this
的父属性设置为nullptr
。this
的父代的引用计数ref_count
不会改变,因为该父代仍然具有相同数量的子代,尽管其中一个子代突然从FibTask
类型突变为FibCont
类型。如果你是一个快乐的父母,不要在家里尝试这个!
在这一点上,FibTask
仍然活着,但我们很快就会除掉它。FibTask
已经没有父母了,但临死前还在负责一些杂务。FibTask
先造两个FibTask
孩子,但是小心!
-
新任务
a
和b
现在是c
(不是this
)的子任务,因为我们使用c.allocate_child()
而不仅仅是allocate_child()
来分配它们。换句话说,c
现在是a
和b
的继承者。 -
子项的结果不再写入堆栈存储的变量中。初始化
a
时,现在调用的构造器是FibTask(n-1,
&c.
x)
,所以子任务a
(a.sum
)中的指针sum
实际上是指向c.x
。同样,b.sum
指向c.y
。 -
由于
FibCont c
实际上只有两个子节点(a
和b
),所以c
(内部和私有c.ref_count
)的引用计数仅被设置为两个(c.set_ref_count(2)
)。
现在子任务a
和b
已经准备好被衍生,这就是FibTask
的所有职责。现在它可以平静地死去,它所占用的内存也可以安全地被回收。愿死者安息
注意
正如我们在上一节中提到的,当遵循阻塞风格时,如果一个任务A
产生了k
子任务并使用wait_for_all
成员函数等待它们,那么A.ref_count
必须被设置为k+1
。额外的“1
”说明了任务A
在结束和分派A
的父任务之前必须完成的额外工作。当遵循延续传递风格时,不需要这个额外的“1
”,因为 A 将额外的工作转移到延续任务C
。在这种情况下,如果C.ref_count
有k
子节点,则C.ref_count
必须准确设置为k
。
为了更好地说明这一切是如何工作的,现在我们遵循延续传递的风格,图 10-12 和 10-13 包含了这个过程的一些快照。
图 10-12
parallel_fib(8)
与cutoff=7
的连续传球方式
在图 10-12 的上部,根FibTask(8,&sum)
已经创建了延续FibCont(sum)
和任务FibTask(7,&c.x)
和FibTask(6,&c.y)
,它们实际上是FibCont
的子节点。在堆栈中,我们看到我们只存储了最终结果的和,这是因为x
和y
没有使用这种风格的堆栈空间。现在,x
和y
是FibCont
的成员变量,存储在堆中。在这个图的底部,我们看到原来的根任务已经消失了,它使用了所有的内存。本质上,我们是用堆栈空间交换堆空间,用FibCont
的对象交换FibTask
的对象,如果FibCont
的对象更小,这是有益的。我们还看到从FibTask(7,&c.x)
到根FibCont(&sum)
的父引用已经转移到了更年轻的FibCont
。
图 10-13
延续-传递样式示例(延续!!)
在图 10-13 的顶部,我们开始递归算法的展开部分。不再有FibTask
物体的痕迹。子任务FibTask(6,&c.x)
和FibTask(5,&c.y)
已经求助于基例(n<cutoff
,假设cutoff=7
,分别用 8 和 5 写完∗sum
后即将返回。每个子任务都将返回nullptr
,因此工作线程再次取得控制权,并返回到窃取工作的调度程序,减少父任务的ref_count
,并检查ref_count
是否为 0。在这种情况下,按照第九章的图 9-14 所示的 TBB 任务分派循环的高级描述,下一个要执行的任务是父任务(在这种情况下为FibCont
)。与阻塞风格相反,现在这是立即执行的。在图 10-13 的底部,我们看到原始根任务的两个子任务已经写出了它们的结果。
图 10-14
parallel_fib
等待FibCont
完成,这要感谢一个拥有自己的ref_count
的虚拟任务
您可能想知道parallel_fib
函数是否仍然在spawn_root_and_wait(a)
中等待第一个被创建的根任务,因为这个原始的FibTask
被第一个FibCont
对象替换,然后死亡(见图 10-12 )。嗯,事实上parallel_fib
还在等待,因为spawn_root_and_wait
被设计成可以在连续传球风格下正常工作。对spawn_root_and_wait(x)
的调用实际上并不等待x
完成。相反,它构造了一个x
的伪后继,并等待后继的ref_count
变成0
。因为allocate_continuation
将父引用转发给延续,所以伪后继的ref_count
不会递减,直到延续FibCont
完成。如图 10-14 所示。
绕过调度程序
调度程序绕过是一种优化,在这种优化中,您直接指定要运行的下一个任务,而不是让调度程序挑选。延续传递风格经常为调度程序旁路打开了机会。例如,在延续传递的例子中,一旦FibTask::execute()
返回,根据第九章中描述的工作窃取调度器的获取规则,任务a
总是从就绪池中获取的下一个任务,因为它是最后一个被产生的任务(除非它已经被另一个工作线程窃取)。更确切地说,事件的顺序如下:
-
将任务
a
推到线程的队列中。 -
从成员函数
execute()
返回。 -
从线程的队列中弹出任务
a
,除非它被另一个线程窃取。
将任务放入 deque,然后再取出会导致一些可以避免的开销,或者更糟的是,允许在不增加显著并行性的情况下破坏局部性的窃取。为了避免这两个问题,确保execute
不会产生任务,而是返回一个指向它的指针作为结果。这种方法保证了同一个工作线程立即执行a
,而不是其他线程。为此,在图 10-11 的代码中,我们需要将这两行替换如下:
低级任务接口:第三部分——任务回收
除了绕过调度程序,我们可能还想绕过任务分配和释放。这种机会经常出现在绕过调度程序的递归任务中,因为当父任务完成时,子任务会在返回时立即启动。图 10-15 显示了在斐波纳契例子中实现任务循环所需的变化。
图 10-15
遵循并行斐波那契的任务循环风格
之前叫a
的孩子现在是回收的this
。recycle_as_child_of(c)
这个称呼有几个影响:
-
它标记
this
在 execute 返回时不被自动销毁。 -
它将
this
的后继者设置为c
。为了防止引用计数问题,recycle_as_child_of
有一个先决条件,即this
必须有一个nullptr
后继(this
的父引用应该指向nullptr
)。allocate_continuation
发生后就是这种情况。
成员变量必须被更新以模仿先前使用构造器FibTask(n-1,&c.x)
实现的内容。在这种情况下,this->n
递减(n -=1;
),并且this->sum
被初始化为指向c.x
。
回收时,确保在产生回收的任务后,this
的成员变量没有在任务的当前执行中使用。在我们的例子中就是这种情况,因为回收的任务实际上并没有产生,只会在返回指针this
后运行。您可以生成回收的任务(即spawn (∗this); return nullptr;
),只要在生成后没有使用它的成员变量。这种限制甚至适用于const
成员变量,因为在任务产生之后,它可能会在父任务进一步发展之前运行并被销毁。一个类似的成员函数,task::recycle_as_continuation()
,回收一个任务作为延续,而不是作为子任务。
在图 10-16 中,我们展示了一旦FibCont
的孩子更新了成员变量(8
变成了7
并且 sum 指向了c.x
)时,回收FibTask(8,&sum)
作为FibCont
的孩子的效果。
图 10-16
回收FibTask(8,&sum)
作为FibCont
的孩子
注意
更环保(也更容易)的并行编程☺
通过使用 TBB,对可组合性、延续和任务回收的接受对使并行编程变得更加容易产生了强大的影响。考虑到回收已经在世界范围内获得了青睐,任务的回收也确实有助于节约能源!加入更绿色的并行编程运动——它也让有效的编程变得更容易,这没有坏处!
调度器旁路和任务回收是强大的工具,可以带来显著的改进和代码优化。它们实际上是用来实现第 2 和 3 章中介绍的高级模板,我们也可以利用它们来设计其他满足我们需求的定制高级模板。流程图(第章 3 和第章 17 中的更多内容)鼓励使用continue_node
(以及其他具有调度程序旁路潜力的节点)。在下一节中,我们将展示一个例子,在这个例子中,我们利用低级任务 API 并评估其影响,但在此之前,请先查看我们的“清单”
任务界面清单
求助于 task 接口对于具有大量 fork 的 fork-join 并行性是可取的,这样任务窃取可以导致足够的广度优先行为来占用线程,然后线程以深度优先的方式进行管理,直到它们需要窃取更多的工作。换句话说,任务调度器的基本策略是“广度优先偷窃和深度优先工作”广度优先盗窃规则充分提高了并行性,使线程保持忙碌。深度优先工作规则使每个线程在有足够的工作要做时保持高效运行。
请记住,这不是最简单的 API,而是专门为速度而设计的。在许多情况下,我们面临的问题可以通过使用更高级的接口来解决,就像模板parallel_for
、parallel_reduce
等等所做的那样。如果情况不是这样,并且您需要任务 API 提供的额外性能,那么需要记住一些细节
-
总是使用
new(allocation_method) T
来分配一个任务,其中allocation_method
是类任务的分配方法之一(见附录 B,图 B-76)。不要创建任务的本地或文件范围的实例。 -
所有的兄弟都应该在任何开始运行之前分配,除非你正在使用
allocate_additional_child_of
。我们将在本章的最后一节详细阐述这一点。 -
利用延续传递、调度程序旁路和任务回收来挤出最大性能。
-
如果任务完成,并且没有被标记为重新执行(回收),它将被自动销毁。此外,它的后继引用计数递减,如果达到零,则自动产生后继。
还有一件事:先进先出(又名发射并忘记)任务
到目前为止,我们已经看到了任务是如何产生的以及产生任务的结果:将任务排入队列的线程很可能是以 LIFO(后进先出)顺序将其排出队列的线程(如果没有其他线程窃取产生的任务)。正如我们所说的,由于“深度优先工作”,这种行为在局部性和限制内存占用方面有一些有益的影响然而,如果随后还产生了一堆任务,那么所产生的任务可能会隐藏在线程的本地队列中。
如果我们喜欢类似 FIFO 的执行顺序,任务应该使用 enqueue 函数而不是 spawn 函数进行排队,如下所示:
我们的示例FifoTask
类从tbb::task
派生而来,并像每个普通任务一样覆盖了execute()
成员函数。衍生任务的四个不同之处是
-
调度器可以推迟一个衍生任务,直到它被等待,但是一个排队的任务最终将被执行,即使没有线程明确地等待该任务。即使工作线程的总数为零,也会创建一个特殊的额外工作线程来执行排队的任务。
-
衍生的任务以类似 LIFO 的顺序进行调度(最近衍生的任务在下一个开始),但是排队的任务以大致(不精确)的 FIFO 顺序进行处理(大致以它们进入队列的顺序开始——“近似”给了 TBB 一些灵活性,使其比严格的策略允许的更高效)。
-
由于深度优先遍历,为了节省内存空间,衍生任务是递归并行的理想选择,但是排队的任务可能会过度消耗递归并行的内存,因为递归将在广度优先遍历中扩展。
-
衍生的父任务应该等待其衍生的子任务完成,但是排队的任务不应该被等待,因为来自程序的不相关部分的其他排队的任务可能必须首先被处理。使用排队任务的推荐模式是让它异步发出完成信号。本质上,排队的任务应该作为根任务分配,而不是作为等待的子任务。
在第十四章中,排队的任务也在一些任务优先于其他任务的情况下进行了说明。《线程构建模块设计模式手册》中还描述了另外两个用例(参见本章末尾的“更多信息”)。有两种设计模式可以让排队的任务派上用场。在第一种情况下,即使用户启动了长时间运行的任务,GUI 线程也必须保持响应。在提出的解决方案中,GUI 线程将任务排队,但不等待它完成。该任务执行繁重的工作,然后在终止前用一条消息通知 GUI 线程。第二种设计模式也与给不同的任务分配非抢占式优先级有关。
让这些底层特性发挥作用
让我们切换到一个更具挑战性的应用程序来评估不同的基于任务的实现方案的影响。波前是一种出现在科学应用中的编程模式,例如基于动态编程或序列比对的应用。在这种模式中,数据元素分布在表示逻辑平面或空间的多维网格上。元素必须按顺序计算,因为它们之间存在依赖关系。一个例子是我们在图 10-17 中展示的 2D 波前。这里,计算从矩阵的一个角开始,扫描将沿着对角线轨迹穿过平面进行到对角。每个反对角线代表可以并行执行的计算或元素的数量,它们之间没有相关性。
图 10-17
典型的 2D 波前图案(a)和转换成原子计数器矩阵的相关性(b)
在图 10-18 的代码中,我们为nxn
2D 网格的每个单元计算一个函数。每个单元与相邻单元的两个元素具有数据相关性。例如,在图 10-17 (a)中,我们看到单元(2,3)依赖于北面(1,3)和西面(2,2)的单元,因为在i
和j
循环的每次迭代中,都需要以前迭代中计算的单元:A[i,j]
依赖于A[i-1,j]
(北面依赖)和A[i,j-1]
(西面依赖)。在图 10-18 中,我们展示了数组 A 已被线性化的计算的顺序版本。显然,抗角细胞是完全独立的,因此它们可以并行计算。为了利用这种并行性(循环“i
”和“j
”),任务将在迭代空间(或从现在开始的任务空间)内执行对应于每个单元的计算,并且独立的任务将被并行执行。
图 10-18
2D 波前问题的代码片段。阵列 A 是 2D 网格的线性化视图。
在我们的任务并行化策略中,基本工作单元是由函数foo
在矩阵的每个(i,j
)单元执行的计算。不失一般性,我们假设每个单元的计算负荷将由foo
函数的gs
(粒度)参数控制。这样,我们可以定义任务的粒度,因此,我们可以根据任务粒度研究不同实现的性能,以及同构或异构任务负载的情况。
在图 10-17(b) 中,箭头显示了我们的波前问题的数据依赖流。例如,在执行了不依赖于任何其他任务的左上任务(1, 1
之后,可以分派两个新任务(一个在(2, 1)
下方,一个在右侧(1, 2)
)。这种相关性信息可以通过带有计数器的 2D 矩阵来获取,如图 10-17(b) 所示。计数器的值指出我们必须等待多少任务。只能调度相应计数器无效的任务。
实现这种波前计算的替代方案在英特尔 TBB 设计模式中有所介绍(请参阅“了解更多信息”),其中实现了非循环任务的一般图表。这个版本可以和本章的源代码一起以wavefront_v0_DAG.cpp
的名字获得。然而,该版本要求预先分配所有任务,我们接下来介绍的实现更加灵活,可以进行调整以更好地利用本地性,我们将在后面看到这一点。在图 10-19 中,我们展示了第一个基于任务的实现,我们称之为wavefront_v1_addchild
。每个就绪任务首先执行任务体,然后它将减少依赖它的任务的计数器。如果该递减操作以计数器等于 0 结束,则该任务还负责产生新的独立任务。请注意,计数器是共享的,并且将被并行运行的不同任务修改。为了说明这个问题,计数器是原子变量(参见第五章)。
图 10-19
摘自 wavefront_v1_addchild 版本的代码
注意,在图 10-19 中,我们使用allocate_additional_child_of(∗parent())
作为新任务的分配方法。通过使用这种分配方法,我们可以在其他人运行时添加孩子。从积极的方面来看,这允许我们节省一些代码,这些代码对于确保在产生任何子任务之前分配所有子任务是必要的(因为这取决于东部任务、南部任务或者两者都准备好被分派)。从负面来看,这种分配方法要求父节点的ref_count
自动更新(当分配一个additional_child
时递增,当任何子节点死亡时递减)。由于我们使用的是allocate_additional_child_of(∗parent())
,所有创建的任务都将是同一个父任务的子任务。任务空间的第一个任务是任务(1, 1)
,它是由
这个根任务的父任务是我们已经在图 10-14 中介绍过的虚拟任务。然后,这段代码中创建的所有任务都会自动更新虚拟任务的ref_count
。
使用allocate_additional_child_of
分配方法的另一个警告是,用户(我们)必须确保在分配额外的子节点之前,父节点的ref_count
不会过早地到达0
。我们的代码已经考虑到了这种可能性,因为分配了一个额外的子节点c
的任务t
已经保证了t
父节点的ref_count
至少为 1,因为t
只会在死亡时(即在分配了c)
之后)减少其父节点的ref_count
。
在第二章中,已经展示了parallel_do_feeder
模板来说明不同的波前应用:正向替换。这个模板本质上实现了一个工作列表算法,通过调用parallel_do_feeder::add()
成员函数可以将新任务动态添加到工作列表中。我们调用wavefront_v2_feeder
到依赖parallel_do_feeder
的波前代码版本,如图 2 中的图 2-19 所示,使用feeder.add()
代替图 10-19 中的 spawn 调用。
如果我们想避免所有的子任务都被一个父任务挂起,并努力自动更新它的ref_count
,我们可以实现一个更精细的版本,模仿前面解释的阻塞风格。图 10-20 显示了这种情况下的execute()
成员函数,这里我们先标注是东、南还是两个单元格都准备好调度,然后分配调度相应的任务。注意,现在我们使用allocate_child()
方法,每个任务最多有两个后代来等待。尽管单个ref_count
的原子更新不再是瓶颈,但是更多的任务正在等待它们的子任务完成(并占用内存)。这个版本将命名为 wavefront_v3_blockstyle。
图 10-20
波前 _v3_blockstyle 版本的 execute()成员函数
现在,让我们也利用延续传递和任务回收的风格。在我们的波前模式中,每个任务都有机会产生两个新任务(东邻和南邻)。我们可以通过返回一个指向下一个任务的指针来避免其中一个任务的产生,所以不是产生一个新的任务,而是当前的任务回收到新的任务中。正如我们已经解释过的,这样我们实现了两个目标:减少任务分配、调用spawn()
的数量,以及节省从本地队列获取新任务的时间。由此产生的版本被称为wavefront_v4_recycle
,主要优点是它将产生的数量从n x n
—2n
(以前版本中产生的数量)减少到n
—2
(大约一列的大小)。请参阅随附的源代码,了解完整的实现。
此外,在回收时,我们可以向调度程序提供关于如何区分任务执行优先级的提示,例如,保证数据结构的缓存感知遍历,这可能有助于改善数据局部性。在图 10-21 中,我们看到了wavefront_v5_locality
版本的代码片段,其中包含了这个优化。如果在执行任务的东边有一个准备分派的任务,我们设置标志recycle_into_east
。否则,我们就设定recycle_into_south
号标志,如果南下任务准备分派。稍后,根据这些标志,我们将当前任务循环到东边或南边的任务中。注意,由于在这个例子中数据结构是按行存储的,如果 east 和 south 任务都准备好了,那么通过回收到 east 任务中可以更好地利用数据缓存。这样,执行当前任务的同一个线程/内核将负责处理遍历邻居数据的任务,因此我们充分利用了空间局部性。因此,在这种情况下,我们循环到东部任务,并生成一个稍后执行的新的南部任务。
图 10-21
wavefront_v5_locality 版本的 execute()成员函数
对于巨大的波前问题,减少每个分配任务的足迹可能是相关的。根据您是否喜欢使用全局变量,您可以考虑在全局变量中存储所有任务的共享全局状态(n
、g
s、A
和counters
)。这个选项在wavefront_v6_global
中实现,并且在本章示例的源代码目录中提供。
使用设置每个任务浮点操作数量的参数gs
,我们发现对于执行超过 2000 次浮点操作(FLOPs)的粗粒度任务,七个版本之间没有太大差异,代码几乎呈线性扩展。这是因为与计算所有任务所需的大量时间相比,并行开销消失了。然而,对于这种粗粒度的任务,很难找到真正的波前码。在图 10-22 中,我们展示了版本 0 到 5 在四核处理器上实现的加速,更准确地说,是一个 2.6 GHz 的酷睿 i7-6700HQ (Skylake 架构,第六代),6 MB 三级高速缓存和 16 GB RAM。粒度,gs
,仅设置为 200 FLOPs 和n=1024
(对于此n
,版本 6 执行版本 5)。
图 10-22
在不同版本的四个内核上加速
很明显,TBB v5 是这个实验中的最佳解决方案。事实上,我们测量了其他更细粒度大小的加速,发现粒度越细,v4 和 v5 相对于 v1 和 v2 的改进就越好。此外,有趣的是,v4 对 v1 版本的增强指出,大量的改进贡献是由于回收优化。A. Dios 在本章末尾列出的论文中进行了更详细的研究。
由于波前算法的性能会随着任务工作负载粒度变得更细而下降,因此一种众所周知的抵消这种趋势的技术是平铺(有关简要定义,请参见词汇表)。通过平铺,我们实现了几个目标:更好地利用局部性,因为每个任务在一段时间内在数据的空间受限区域内工作;减少任务的数量(从而减少分配和生成的数量);并且节省波前簿记中的一些开销(存储器空间和计数器/相关性矩阵的初始化时间,由于它要求每个块-瓦片一个计数器,而不是每个矩阵元素一个计数器,所以现在它变小了)。在通过平铺来粗化任务的粒度之后,我们又可以自由地进行 v1 或 v2 实现了,对吗?但是,平铺的缺点是减少了独立任务的数量(它们更粗糙,但数量更少)。然后,如果我们需要将我们的应用扩展到大量的内核,而问题的规模没有以相同的速度增长,我们可能必须从 TBB 的低级功能中挤出最后一滴可用性能。在这样具有挑战性的情况下,我们必须展示我们对 TBB 的杰出控制,并且我们已经成功地磨练了我们的并行编程技能。
摘要
在这一章中,我们深入研究了基于任务的替代方案,这些方案对于实现递归、分而治之和 wavefront 等应用特别有用。我们使用斐波那契数列作为一个运行的例子,它是我们第一次与已经讨论过的高级parallel_invoke
并行实现的。然后,我们开始使用由task_group
类提供的中级 API 潜入更深的水域。任务界面提供了更大程度的灵活性来满足我们特定的优化需求。TBB 任务是本书第一部分中介绍的其他高级模板的基础,但我们也可以利用它们来构建我们自己的模式和算法,利用延续传递、调度程序旁路和任务回收等高级技术。对于要求更高的开发人员来说,由于我们将在下一章讨论的任务优先级、任务相似性和任务排队特性,更多的可能性是可用的。我们迫不及待地想看看你能从现在你手中的这些强大的工具中创造和发展出什么。
更多信息
以下是我们推荐的一些与本章相关的额外阅读材料:
-
A.迪奥斯,r .阿森约,A .纳瓦罗,f .科尔贝拉,E.L .萨帕塔,基于任务的并行波前模式的案例研究,并行计算的进展:应用,工具和技术,通往万亿次计算之路,国际标准书号:978-1-61499-040-6,第 22 卷,第 65-72 页,荷兰阿姆斯特丹 ios 出版社,2012 年(可在此获得扩展版本:
www.ac.uma.es/~compilacion/publicaciones/UMA-DAC-11-02.pdf
)。 -
A.迪奥斯,r .阿森约,a .纳瓦罗,f .科尔贝拉,E.L .萨帕塔基于任务的并行波前模式的高级模板,IEEE Intl。糖膏剂高性能计算大会(HiPC,2011 年),2011 年 12 月 18 日至 21 日,印度班加罗尔。在 TBB 任务之上实现一个高级模板,以简化波前算法的实现。
-
González Vázquez,Carlos Hugo,基于库的复杂并行模式算法解决方案,博士报告,2015 年。
http://hdl.handle.net/2183/14385
。描述三种复杂的并行模式,并通过在 TBB 任务之上实现高级模板来解决它们。 -
英特尔 TBB 设计模式:
开放存取本章根据知识共享署名-非商业-非专用 4.0 国际许可协议(http://Creative Commons . org/licenses/by-NC-nd/4.0/)的条款进行许可,该协议允许以任何媒体或格式进行任何非商业使用、共享、分发和复制,只要您适当注明原作者和来源,提供知识共享许可协议的链接,并指出您是否修改了许可材料。根据本许可证,您无权共享从本章或其部分内容派生的改编材料。
本章中的图像或其他第三方材料包含在该章的知识共享许可中,除非该材料的信用额度中另有说明。如果材料未包含在本章的知识共享许可中,并且您的预期用途不被法定法规允许或超出了允许的用途,您将需要直接从版权所有者处获得许可。
十一、控制用于执行的线程数量
默认情况下,TBB 库使用通常正确的线程数量来初始化其调度程序。它创建的工作线程比平台上逻辑内核的数量少一个,留下一个内核可用于执行主应用程序线程。因为 TBB 库使用调度到这些线程上的任务来实现并行性,所以这通常是拥有线程的正确数量——每个逻辑内核正好有一个软件线程,TBB 的调度算法使用第九章中描述的工作窃取来有效地将任务分配给这些软件线程。
然而,在许多情况下,我们可能有理由想要更改默认设置。也许我们正在进行扩展实验,并想看看我们的应用程序在不同数量的线程下表现如何。或者,也许我们知道几个应用程序将总是在我们的系统上并行执行,所以我们只想使用应用程序中可用资源的一个子集。或者,我们可能知道我们的应用程序为渲染、人工智能或其他目的创建了额外的本机线程,我们希望限制 TBB,以便在系统上为这些其他本机线程留出空间。在任何情况下,如果我们想改变默认设置,我们可以。
有三个类可用于影响有多少线程参与执行特定的 TBB 算法或流程图。但是这些类之间的交互可能非常复杂!在这一章中,我们将重点放在最常见的案例和最著名的实践上,这些对于除了最复杂的应用程序之外的所有应用程序来说都足够了。这种详细程度对大多数读者来说已经足够了,我们提出的建议对几乎所有情况都足够了。即便如此,想要了解 TBB 最底层的具体细节的读者,如果愿意的话,也可以查阅 TBB 文档,了解这些类之间可能的交互的所有细节。但是如果你遵循本章概述的模式,我们不认为这是必要的。
TBB 调度程序架构的简要概述
在我们开始讨论控制用于执行并行算法的线程数量之前,让我们回忆一下图 11-1 所示的 TBB 调度程序的结构。在第九章中可以找到关于 TBB 调度器的更深入的描述。
全局线程池(市场)是所有工作线程在迁移到任务竞技场之前的起点。线程迁移到有任务可执行的任务区域,并且如果没有足够的线程来填充所有区域中的所有槽,则线程与区域中的槽数量成比例地填充槽。例如,一个任务竞技场的槽数是另一个竞技场的两倍,那么它将接收大约两倍的工人。
注意
如果正在使用任务优先级,工作线程将在用较低优先级任务填充任务区域中的槽之前,完全满足来自具有较高优先级任务的任务区域的请求。我们将在第十四章中详细讨论任务优先级。在本章的其余部分,我们假设所有的任务都具有同等的优先级。
图 11-1
TBB 任务调度器的体系结构
任务竞技场有两种创建方式:(1)默认情况下,每个主线程在执行 TBB 算法或产生任务时都有自己的竞技场;(2)我们可以使用class task_arena
显式创建任务竞技场,详见第十二章。
如果一个任务竞技场用完了工作,它的工作线程返回到全局线程池,在其他竞技场中寻找工作,或者在任何竞技场都没有工作的情况下休眠。
用于控制线程数量的接口
TBB 库在十多年前首次发布,在这段时间里,它随着平台和工作负载的发展而发展。现在,TBB 提供了三种控制线程的方法:task_scheduler_init
、task_arena
和global_control
。在简单的应用程序中,我们可能只需要使用这些接口中的一个来完成我们需要的一切,但是在更复杂的应用程序中,我们可能需要使用这些接口的组合。
用task_scheduler_init
控制线程数
当 TBB 库第一次发布时,只有一个控制应用程序中线程数量的接口:class task_scheduler_init
。该类的接口如图 11-2 所示。
task_scheduler_init
对象可用于(1)控制何时构建和销毁与主线程相关联的任务场所;(2)设置该线程的领域中的工作者槽的数量;(3)为竞技场中的每个工作者线程设置堆栈大小;如果需要的话,(4)对全局线程池中可用的线程数量设置一个初始的软限制(见侧栏)。
图 11-2
task_scheduler_init
类接口
用task_arena
控制线程数
后来,随着 TBB 被用于更大的系统和更复杂的应用程序中,class task_arena
被添加到库中,以创建显式任务竞技场,作为隔离工作的一种方式。工作隔离将在第十二章中详细讨论。在这一章中,我们关注的是class task_arena
如何让我们设置那些显式竞技场中可用的槽数。本章使用的class task_arena
中的功能如图 11-3 所示。
使用task_arena
构造器,我们可以使用max_concurrency
参数设置 arena 中的插槽总数,使用reserved_for_masters
参数设置为主线程专门保留的插槽数量。当我们将一个仿函数传递给execute
方法时,调用线程连接到 arena,并且从仿函数中产生的任何任务都被产生到该 arena 中。
图 11-3
task_arena
类接口
软限制和硬限制
全局线程池既有一个软限制又有一个硬限制。可用于并行执行的工作线程数量等于软限制值和硬限制值中的最小值。
软限制是应用程序中的task_scheduler_init
和global_control
对象发出的请求的函数。硬限制是系统上逻辑核心数量P
的函数。在写这本书的时候,对于平台有 256 个线程的硬限制,对于平台有P <= 64
,对于平台有64 < P <= 128
有 4 个P
,对于平台有P > 128
有 2 个P
。
TBB 任务在 TBB 工作线程上非抢占式执行。因此,超额订阅拥有比逻辑内核多得多的 TBB 线程的系统没有太大意义——只是有更多的线程需要操作系统管理。如果我们想要的 TBB 线程比硬限制允许的要多,几乎可以肯定,我们要么是错误地使用了 TBB,要么是试图完成一些 TBB 没有设计的事情。
用global_control
控制线程数
在class task_arena
被引入到库中之后,TBB 用户开始请求一个接口来直接控制全局线程池中可用的线程数量。在 TBB 2019 更新 4 之前,class global_control
只是一个预览功能(它现在是一个完整的功能——这意味着它在默认情况下可用,无需启用预览宏定义),用于更改 TBB 任务调度器使用的全局参数值——包括全局线程池中可用线程数量的软限制。
class global_control
的等级定义如图 11-4 所示。
图 11-4
global_control
类接口
概念和类别概述
本章中使用的概念和各种类的效果在本节中进行了总结。不要太担心理解这里介绍的所有细节。在下一节中,我们将介绍使用这些类来实现特定目标的最著名的方法。因此,尽管这里描述的交互可能看起来很复杂,但典型的使用模式要简单得多。
调度器:TBB 调度器指的是全局线程池和至少一个任务竞技场。一旦构建了 TBB 调度器,可以向其添加额外的任务领域,增加调度器上的引用计数。当任务竞技场被销毁时,它们会减少调度程序上的引用计数。如果最后一个任务竞技场被破坏,TBB 调度器也被破坏,包括全局线程池。未来使用 TBB 任务将需要构建一个新的 TBB 调度程序。一个进程中绝不会有多个 TBB 调度程序处于活动状态。
硬线程限制:TBB 调度程序创建的工作线程总数有一个硬限制。这是平台硬件并发性的一个功能(更多细节参见软和硬限制)。
软线程限制:对 TBB 调度器可用的工作线程数量有一个动态的软限制。一个global_control
对象可用于直接改变软限制。否则,软限制由创建调度程序的线程初始化(更多细节见软和硬限制)。
默认软线程限制:如果一个线程产生了一个 TBB 任务,无论是直接通过使用低级接口还是间接通过使用 TBB 算法或流图,如果当时不存在 TBB 调度器,将会创建一个调度器。如果没有global_control
对象设置了明确的软限制,则软限制被初始化为P
-1,其中P
是平台的硬件并发性。
global_control
对象:一个global_control
对象在其生命周期内影响 TBB 调度程序可以使用的工作线程数量的软限制。在任一时间点,软限制是活动的global_control
对象请求的所有max_concurrency_limit
值的最小值。如果软限制在任何活动的global_control
对象被构造之前被初始化,当寻找最小值时,这个初始值也被考虑。当global_control
对象被破坏时,如果被破坏的对象是限制max_concurrency_limit
值,软限制可能增加。创建一个global_control
对象不会初始化 TBB 调度程序,也不会增加调度程序的引用计数。当最后一个global_control
对象被销毁时,软限制被重置为默认的软线程限制。
task_scheduler_init
对象:一个task_scheduler_init
对象创建一个与主线程相关联的任务竞技场,但前提是对于该线程还没有一个任务竞技场。如果一个已经存在,task_scheduler_init
对象增加任务竞技场的引用计数。当一个task_scheduler_init
对象被销毁时,它减少引用计数,如果新计数为零,任务竞技场被销毁。如果在构造task_scheduler_init
对象时不存在 TBB 调度程序,则会创建一个 TBB 调度程序,如果global_control
对象没有设置软线程限制,则会使用构造器的max_threads
参数对其进行初始化,如下所示:
task_arena
对象:一个task_arena
对象创建一个不与特定主线程相关联的显式任务竞技场。底层任务竞技场并不是在构造器期间立即初始化,而是在第一次使用时才初始化(在本章的示例中,我们展示的是对象的构造,而不是底层任务竞技场的表示)。如果一个线程在初始化它自己的隐式任务竞技场之前将一个任务生成或排队到一个显式task_arena
中,这个动作就像是该线程的 TBB 调度器的第一次使用——包括它的隐式任务竞技场的默认初始化和软限制的可能初始化的所有副作用。
设置线程数量的最佳方法
task_scheduler_init
、task_arena
和global_control
类的组合提供了一套强大的工具,用于控制可以参与执行 TBB 并行工作的线程数量。
当以超出预期模式的方式组合时,这些对象的交互可能会令人困惑。因此,在本节中,我们将重点关注常见的场景,并提供使用这些类的推荐方法。为简单起见,我们在本节展示的图中,假设我们正在支持四个逻辑核心的系统上执行。在这样的系统上,TBB 库将默认创建三个工作线程,并且在任何默认的任务竞技场中都将有四个槽,其中一个槽保留给主线程。在我们的图中,我们显示了全局线程池中可用的线程数量和任务舞台中的槽数量。为了减少图中的混乱,我们没有显示被分配到插槽的工人。向下箭头用于指示对象的生存期。一个大“X”表示一个物体的破坏。
为简单的应用程序使用单个task_scheduler_init
对象
最简单,也可能是最常见的场景是,我们有一个只有一个主线程的应用程序,没有明确的任务舞台。应用可能有许多 TBB 算法,包括嵌套并行的使用,但没有一个以上的用户创建的线程,即主线程。如果我们不采取任何措施来控制 TBB 库管理的线程数量,当主线程第一次通过生成任务、执行 TBB 算法或使用 TBB 流图与 TBB 调度程序进行交互时,就会为主线程创建一个隐式任务竞技场。创建这个默认任务竞技场时,全局线程池中的线程数量将比系统中逻辑核心的数量少一个。在图 11-5 中,针对具有四个逻辑内核的系统说明了这种最基本的情况,以及所有默认初始化。
图 11-5
全局线程池的默认初始化和主线程的单个任务竞技场
在 Github 的ch11/fig_11_05.cpp
中可以获得示例代码,并对其进行了检测,以便打印出代码每一部分中有多少线程参与的摘要。本章中的许多示例都采用了类似的方法。这个工具没有在图中的源代码中显示,但是可以在 Github 的代码中找到。在具有四个逻辑核心的系统上运行此示例会产生类似于以下内容的输出
There are 4 logical cores.
4 threads participated in 1st pfor
4 threads participated in 2nd pfor
4 threads participated in flow graph
如果我们在这个最简单的场景中想要不同的行为,class task_scheduler_init
足以控制线程的数量。我们需要做的就是在第一次使用 TBB 任务之前创建一个task_scheduler_init
对象,并向它传递我们希望应用程序使用的线程数量。图 11-6 显示了一个例子。这个对象的构造创建了任务调度器,用适当数量的线程填充全局线程池(market )(至少足以填充任务竞技场 1 中的槽),并用请求数量的槽为主线程构造单个竞技场。当单个task_scheduler_init
对象被销毁时,这个 TBB 调度程序也被销毁。
图 11-6
为简单的应用程序使用单个task_scheduler_init
对象
执行图 11-6 的代码将产生一个输出:
There are 4 logical cores.
8 threads participated in 1st pfor
8 threads participated in 2nd pfor
8 threads participated in flow graph
注意
当然,静态编码要使用的线程数量是一个非常糟糕的主意。我们用易于理解的具体数字示例来说明功能。为了编写可移植的和更永恒的代码,我们几乎从不建议编码特定的数字。
在一个简单的应用程序中使用多个task_scheduler_init
对象
一个稍微复杂一点的用例是,我们仍然只有一个应用程序线程,但是我们希望在应用程序的不同阶段使用不同数量的线程来执行。只要我们不重叠task_scheduler_init
对象的生命周期,我们可以通过创建和销毁使用不同max_threads
值的task_scheduler_init
对象来改变应用程序执行期间的线程数量。使用这种方法的一个常见场景是在缩放实验中。图 11-7 显示了一个在 1 到 P 个线程上运行测试的循环。在这里,我们创建并销毁一系列的task_scheduler_init
对象,以及支持不同数量线程的 TBB 调度程序。
图 11-7
使用 1 到 P 个线程运行测试的简单计时循环
在图 11-7 中,每次我们创建task_scheduler_init
对象init
时,库为主线程创建一个任务竞技场,其中一个槽为主线程保留,另外还有i-1
槽。同时,它设置软限制并用至少i-1
个工作线程填充全局线程池(记住,如果max_threads
是< P-1
,它仍然在全局线程池中创建P-
1 个线程)。当init
被销毁时,TBB 调度程序也被销毁,包括单任务竞技场和全局线程池。
运行示例代码的输出,其中run_test()
包含一个工作时间为 400 毫秒的parallel_for
,结果类似于
Test using 1 threads took 0.401094seconds
Test using 2 threads took 0.200297seconds
Test using 3 threads took 0.140212seconds
Test using 4 threads took 0.100435seconds
使用具有不同槽数的多个竞技场来影响 TBB 放置其工作线程的位置
现在让我们探索更复杂的场景,其中我们有不止一个任务舞台。出现这种情况最常见的原因是我们的应用程序有多个应用程序线程。这些线程中的每一个都是主线程,并拥有自己的隐式任务竞技场。我们也可以有不止一个任务竞技场,因为我们使用class task_arena
显式创建竞技场,如第十二章所述。不管我们在一个应用程序中如何处理多个任务区域,工作线程都会按照它们拥有的槽的数量成比例地迁移到任务区域。并且线程只考虑有任务可执行的任务区域。正如我们前面提到的,我们在本章中假设所有的任务都具有同等的优先级。任务优先级会影响线程如何迁移到竞技场,在第十四章中有更详细的描述。
图 11-8 显示了一个总共有三个任务竞技场的例子:两个为主线程创建的任务竞技场(主线程和线程t
)和一个显式任务竞技场a
。这个例子是人为设计的,但是展示了足够复杂的代码来表达我们的观点。
在图 11-8 中,没有试图控制应用中的线程数量或任务区域中的插槽数量。因此,每个 arena 都用默认数量的槽来构造,全局线程池用默认数量的工作线程来初始化,如图 11-9 所示。
图 11-8
一个有三个任务竞技场的应用程序:主线程的默认竞技场,一个显式的task_arena a
,和一个主线程的默认任务竞技场t
因为我们现在有不止一个线程,所以我们使用图 11-9 中的垂直位置来表示时间;图中较低的对象是在图中较高的对象之后构建的。该图显示了一种可能的执行顺序,在我们的示例中,线程t
是第一个使用parallel_for
生成任务的线程,因此它创建了 TBB 调度器和全局线程池。尽管这个例子看起来很复杂,但是行为是很好定义的。
图 11-9
有三个任务领域的示例的可能执行
如图 11-9 所示,线程t
和任务竞技场a
中parallel_for
算法的执行可能会重叠。如果是这样,全局线程池中的三个线程在它们之间分配。由于有三个工作线程,一个 arena 最初将获得一个工作线程,另一个最初将获得两个工作线程。哪个竞技场获得的线程更少取决于库的判断,当其中一个竞技场耗尽工作时,线程可以迁移到另一个竞技场来帮助完成那里的剩余工作。在图 11-9 的主线程中完成对a.execute
的调用后,最终的parallel_for
在主线程的默认竞技场中执行,主线程填充其主槽。如果此时线程t
中的parallel_for
也完成了,那么所有三个工作线程都可以迁移到主线程的竞技场来处理最终的算法。
图 11-9 中显示的默认行为很有意义。我们的系统只有四个逻辑内核,所以 TBB 用三个线程初始化全局线程池。当创建每个任务竞技场时,TBB 不会向全局线程池添加更多线程,因为平台仍然有相同数量的内核。相反,全局线程池中的三个线程在任务舞台之间动态共享。
TBB 库按照线程拥有的槽数比例分配线程到任务竞技场。但是我们不必满足于任务竞技场的默认槽数。我们可以通过为每个应用程序线程创建一个task_scheduler_init
对象和/或通过向显式task_arena
对象传递一个max_concurrency
参数来控制不同领域中的插槽数量。图 11-10 显示了一个修改后的例子。
图 11-10
具有三个任务领域的应用程序:主线程的默认领域的最大并发数为 4,显式task_arena a
的最大并发数为 3,主线程 t 的默认领域的最大并发数为 2。
现在,当我们执行应用程序时,TBB 库最多只能提供一个工作线程到线程t
的 arena,因为它只有一个工作线程的插槽,剩下的两个可以分配给 arena a
中的parallel_for
。我们可以在图 11-11 中看到一个执行示例。
图 11-11
在我们显式地设置了各个领域中的槽的数量之后,有可能执行具有三个任务领域的示例
执行 Github 的示例代码,跟踪每个部分中有多少线程参与,显示的输出如下
There are 4 logical cores.
3 threads participated in arena pfor
4 threads participated in main pfor
2 threads participated in std::thread pfor
因为我们已经限制了线程t
可用的槽的数量,所以其他线程在完成它们的工作后不能再从task_arena a
迁移到线程t
。当我们限制插槽时,我们需要谨慎。在这个简单的例子中,我们有利于task_arena a
的倾斜执行,但也限制了多少空闲线程可以协助线程t
。
我们现在已经控制了任务竞技场中线程的槽数,但是仍然依赖 TBB 在全局线程池中分配的默认线程数来填充这些槽。如果我们想改变可用于填充槽的线程数量,我们需要求助于class global_control
。
使用global_control
来控制有多少线程可用于填充竞技场插槽
让我们再来看一下上一节的例子,但是要将全局线程池中的线程数量增加一倍。我们的新实现如图 11-12 所示。
图 11-12
一个有三个任务舞台和一个global_control
对象的应用程序
我们现在使用一个global_control
对象来设置全局线程池中的线程数量。请记住,global_control
对象用于影响调度程序使用的全局参数;在这种情况下,我们正在改变max_allowed_parallelism
参数。我们还使用线程t
中的task_scheduler_init
对象和task_arena
构造器的参数来设置可以分配给每个任务区域的最大线程数。图 11-13 显示了我们的四核机器上的一个执行示例。应用程序现在创建了七个工作线程(总共八个线程减去已经可用的主线程),并且工作线程在显式线程task_arena a
和默认线程t
之间不相等地分配。由于我们没有为主线程做什么特别的事情,最终的parallel_for
使用了它默认的带有 P 个槽的任务竞技场。
图 11-13
在我们使用一个global_control
对象显式地设置了软限制之后,一个可能的例子执行了三个任务领域
执行图 11-13 的示例代码会产生类似如下的输出
There are 4 logical cores.
6 threads participated in arena pfor
4 threads participated in main pfor
2 threads participated in std::thread pfor
使用global_control
临时限制可用线程的数量
另一个常见的场景是使用global_control
对象来临时改变应用程序特定阶段的线程数量,如图 11-14 所示。在这个例子中,主线程通过构造一个task_scheduler_init
对象创建了一个线程池和任务竞技场,可以支持 12 个工作线程。但是一个global_control
对象被用来将一个特定的parallel_for
限制为只有七个工作线程。虽然 task arena 在整个应用程序中保留了 12 个槽,但线程池中可用的线程数量会暂时减少,因此 task arena 中最多有 7 个槽可以被 workers 填充。
图 11-14
使用global_control
对象临时改变特定算法实例可用的线程数量,然后返回默认设置
当global_control
对象被销毁时,使用任何剩余的global_control
对象重新计算软限制。因为没有,所以软限制被设置为默认软限制。这种可能出乎意料的行为值得注意,因为如果我们想在全局线程池中维护 11 个线程,我们需要创建一个外部的global_control
对象。我们在图 11-15 中展示了这一点。
在图 11-14 和 11-15 中,我们不能使用task_scheduler_init
对象来临时改变线程的数量,因为主线程已经存在一个任务竞技场。如果我们在内部作用域中创建另一个task_scheduler_init
对象,它只会增加该任务领域的引用计数,而不会创建新的对象。因此,我们使用一个global_control
对象来限制可用线程的数量,而不是减少 arena 插槽的数量。
如果我们执行图 11-14 中的代码,我们会看到类似如下的输出
图 11-15
使用global_control
对象临时改变特定算法实例可用的线程数量
There are 4 logical cores.
12 threads participated in 1st pfor
8 threads participated in 2nd pfor
4 threads participated in 3rd pfor
添加外部global_control
对象后,如图 11-15 所示,结果输出为
There are 4 logical cores.
12 threads participated in 1st pfor
8 threads participated in 2nd pfor
12 threads participated in 3rd pfor
何时不控制线程数量
当实现一个插件或库时,最好避免使用global_control
对象。这些对象影响全局参数,所以我们的插件或库函数将改变应用程序中所有组件可用的线程数量。鉴于插件或库的本地视图,这可能不是它应该做的事情。在图 11-14 中,我们临时改变了全局线程池中的线程数量。如果我们在一个库调用中做这样的事情,它不仅会影响调用线程的任务领域中可用的线程数量,还会影响我们应用程序中的每个任务领域。库函数如何知道这样做是正确的?很可能不会。
我们建议库不要干预全局参数,只把它留给主程序。允许插件的应用程序的开发者应该清楚地向插件作者传达应用程序的并行执行策略是什么,以便他们可以适当地实现他们的插件。
设置工作线程的堆栈大小
task_scheduler_init
和global_control
类也可以用来设置工作线程的堆栈大小。多个对象的交互与用于设置线程数量时相同,只有一个例外。当有多个global_control
对象设置堆栈大小时,堆栈大小是请求值的最大值,而不是最小值。
task_scheduler_init
对象的第二个参数是thread_stack_size
。默认值为 0,指示调度程序使用该平台的默认值。否则,将使用提供的值。
global_control
构造器接受一个参数和值。如果参数自变量是thread_stack_size,
,那么对象改变全局堆栈大小参数的值。与max_allowed_paralleism
值不同,全局thread_stack_size
值是请求值的最大值。
为什么要改变默认堆栈大小?
一个线程的堆栈必须足够大,以容纳在其堆栈上分配的所有内存,包括其调用堆栈上的所有局部变量。当决定需要多少堆栈时,我们必须考虑任务体中的局部变量,还要考虑任务树的递归执行如何导致深度递归,特别是如果我们已经使用任务阻塞实现了自己的基于任务的算法。如果我们不记得这种风格如何导致堆栈使用的爆炸,我们可以回头看看第十章中的章节,低级任务接口:第一部分/任务阻塞。
由于合适的堆栈大小取决于应用程序,不幸的是没有好的经验法则可以分享。TBB 特定于操作系统的缺省值已经是对一个线程典型需求的最佳猜测。
找出哪里出了问题
随着时间的推移,task_scheduler_init
、task_arena
和global_control
类被引入 TBB 图书馆以解决特定的问题。在 TBB 的早期,当很少有应用程序是并行的时候,task_scheduler_init
类就足够了,即使有,也通常只有一个应用程序线程。随着应用程序变得越来越复杂,task_arena
类帮助用户管理应用程序中的隔离。而global_control
类让用户可以更好地控制库使用的全局参数,以进一步管理复杂性。不幸的是,这些功能并不是作为一个内聚设计的一部分一起创建的。结果是,当在我们之前概述的场景之外使用时,它们的行为有时可能是不直观的,即使它们被很好地定义了。
两个最常见的混淆来源是:( 1)知道默认情况下何时创建 TBB 调度程序,( 2)竞相设置全局线程池的软限制。
如果我们创建一个task_scheduler_init
对象,它要么创建一个 TBB 调度器,要么增加调度器上的引用计数(如果它已经存在的话)。TBB 库中的哪些接口表现得像是第一次使用 TBB 调度程序,这可能很难弄清楚。很明显,使用 TBB 流图或生成任务来执行任何 TBB 算法,都是对 TBB 调度程序的使用。但是正如我们前面提到的,即使在显式task_arena
中执行任务也被视为第一次使用 TBB 调度器,这不仅影响显式任务领域,还可能影响调用线程的默认任务领域。使用线程本地存储或者使用一个并发容器怎么样?这些不算。除了密切关注正在使用的接口的含义之外,最好的建议是,如果应用程序使用了意外数量的线程——特别是当您认为自己已经更改了默认线程数量时,如果它使用了默认线程数量——就要寻找默认 TBB 调度程序可能被意外初始化的地方。
混淆的第二个常见原因是争用对可用线程数量的软限制。例如,如果两个应用程序线程并行执行,并且都创建了一个task_scheduler_init
对象,那么第一个创建其对象的线程将设置软限制。在图 11-16 中,在同一应用中同时执行的两个线程都创建了task_scheduler_init
对象——一个请求max_threads=4
,另一个请求max_threads=8
。任务竞技场发生的事情很简单:每个主线程都有自己的任务竞技场,其中包含它所请求的槽数。但是,如果还没有设置全局线程池中线程数量的软限制呢?TBB 库在全局线程池中填充了多少线程?它应该创造3
还是7
还是3+7=10
还是P-1
还是……?
图 11-16
两个task_scheduler_init
对象的并发使用
正如我们在对task_scheduler_init
的描述中所概述的,它不做这些事情。相反,它使用最先出现的请求。是的,你没看错!如果线程 1 碰巧首先创建了它的task_scheduler_init
对象,我们将得到一个 TBB 调度器,它有一个包含三个工作线程的全局线程池。如果线程 2 首先创建它的task_scheduler_init
对象,我们得到一个有七个工作线程的线程池。我们的两个线程可能共享三个工作线程或七个工作线程;这完全取决于谁先赢得创建 TBB 调度程序的竞赛!
但是我们不应该绝望;几乎所有与设置线程数量相关的潜在缺陷都可以通过回到本章前面描述的常见使用模式来解决。例如,如果我们知道我们的应用程序可能会有如图 11-16 所示的竞争,我们可以通过使用global_control
对象在主线程中设置软限制来清楚地表达我们的愿望。
摘要
在本章中,我们简要回顾了 TBB 调度器的结构,然后介绍了用于控制并行执行线程数量的三个类:class task_scheduler_init
、class task_arena
和class global_control
。然后,我们描述了控制并行算法使用的线程数量的常见用例——从只有一个主线程和一个任务舞台的简单用例,到有多个主线程和多个任务舞台的更复杂的用例。我们最后指出,虽然在使用这些类时存在潜在的问题,但我们可以通过小心地使用这些类来避免这些问题,从而使我们的意图清晰,而不依赖于默认行为或比赛的获胜者。
开放存取本章根据知识共享署名-非商业-非专用 4.0 国际许可协议(http://Creative Commons . org/licenses/by-NC-nd/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
不是递归锁,所以存在死锁。线程被捕获,等待自己释放锁!
图 12-1
在执行嵌套的parallel_for
时持有锁
看到这个例子后,两个问题普遍出现:(1)真的有人这样写代码吗?(2)一个线程真的能从外部循环窃取任务吗?不幸的是,这两个问题的答案都是肯定的。
事实上,人们确实会这样写代码——尽管几乎都是无意的。出现这种模式的一种常见方式是在调用库函数时持有锁。开发人员可能认为他们知道一个函数是做什么的,但是如果他们不熟悉它的实现,他们可能就错了。如果库调用包含嵌套并行,结果可能是图 12-1 所示的情况。
是的,偷工减料会导致这个例子死锁。图 12-2 显示了我们的例子是如何陷入这种糟糕的状态的。
图 12-2
图 12-1 中代码生成的任务树的一个潜在执行
在图 12-2(a) 中,线程 t 0 启动外循环并获取m
上的锁。线程 t 0 然后开始嵌套的parallel_for
并执行其迭代空间的左半部分。在线程t
0
忙碌的同时,另外三个线程t
1
t
2
t
3
参与竞技场中任务的执行。线程 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
循环,然后读取线程本地存储位置。由于内部循环,线程可能会在空闲时窃取工作,将另一个值写入线程本地存储位置,然后返回到外部任务。
图 12-3
由于使用线程本地存储,嵌套并行可能会导致意外结果
TBB 开发团队使用术语兼职 1 来描述线程在运行中有未完成的子任务,并窃取不相关的任务来保持忙碌的情况。兼职通常是件好事!这意味着我们的线程没有闲置。只有在有限的情况下事情会出错。在我们的两个例子中,都有一个不好的假设。他们都认为——不足为奇——因为 TBB 有一个非抢占式调度器,所以同一个线程永远不会执行内部任务,然后在完成内部任务之前开始执行外部任务。正如我们所见,由于线程在嵌套并行中等待时会窃取工作,这种情况实际上是可能发生的。只有当我们错误地依赖线程以互斥的方式执行任务时,这种典型的良性行为才是危险的。在第一种情况下,在执行嵌套并行时会持有一个锁,从而允许线程暂停内部任务并获取外部任务。在第二种情况下,线程在嵌套并行之前和之后访问线程本地存储,并假设线程不会在两者之间兼职。
正如我们所看到的,这些例子是不同的,但有一个共同的误解。在本章末尾的“更多信息”部分列出的博客“英特尔线程构建模块中的工作隔离功能”中,Alexei Katranov 提供了一个三步清单,用于确定何时需要工作隔离来确保正确性:
-
是否使用了嵌套并行(即使是间接的,通过第三方库调用)?如果没有,就不需要隔离;否则,转到下一步。
-
对于一个线程来说,重新进入外层并行任务是否安全(就像存在递归一样)?存储到一个线程本地值,重新获取这个线程已经获取的互斥体,或者其他不应该被同一个线程再次使用的资源都可能导致问题。如果重入是安全的,就不需要隔离;否则,转到下一步。
-
需要隔离。嵌套并行必须在隔离区域内调用。
用this_task_arena::isolate
创建一个隔离区域
当我们需要隔离以保证正确性时,我们可以使用this_task_arena
名称空间中的isolate
函数之一:
图 12-4 显示了如何使用该功能在图 12-1 的嵌套parallel_for
周围添加一个隔离区域。在一个隔离区域内,如果一个线程因为必须等待而变得空闲——例如在嵌套的parallel_for
结束时——它将只被允许窃取从它自己的隔离区域内产生的任务。这修复了我们的死锁问题,因为如果一个线程在等待图 12-4 中的内部parallel_for
时偷取,它将不被允许偷取外部任务。
图 12-4
在嵌套并行的情况下,使用隔离功能来防止兼职
当一个线程在一个隔离区域内被阻塞时,它仍然会从它的任务区域中随机选择一个线程进行窃取,但是现在必须检查该受害线程的队列中的任务,以确保它只窃取源自其隔离区域内的任务。
在阿列克谢的博客中,this_task_arena::isolate
的主要特性被很好地总结如下:
-
隔离仅约束进入或加入隔离区域的线程。隔离区域之外的工作线程可以接受任何任务,包括在隔离区域中产生的任务。
-
当一个没有隔离的线程执行一个在隔离区域中产生的任务时,它加入这个任务的区域并且变得隔离,直到任务完成。
-
在隔离区域内等待的线程不能处理在其他隔离区域中产生的任务(即,所有区域都是相互隔离的)。此外,如果隔离区域内的线程进入嵌套的隔离区域,则它不能处理来自外部隔离区域的任务。
哦不!工作隔离会导致其自身的正确性问题!
不幸的是,我们不能只是不加区别地应用工作隔离。这对于性能有影响,我们稍后会谈到,但更重要的是,如果使用不当,工作隔离本身会导致死锁!又来了…
特别是,当我们将工作隔离与 TBB 接口混合时,我们必须格外小心,这些接口将生成任务与等待任务分开——例如task_group
和流程图。在一个隔离区域中调用等待接口的任务在等待时不能参与另一个隔离区域中产生的任务。如果有足够多的线程卡在这样的位置,应用程序可能会耗尽线程,向前的进程将会停止。
让我们考虑图 12-5 所示的示例函数。在函数splitRunAndWait, M
中,任务在task_group tg
中产生。但是每次产卵都发生在不同的隔离区域。
图 12-5
在task_group tg
上调用run
和wait
的函数。对run
的调用是从一个隔离区域内发出的。
如果我们直接调用函数fig_12_5
,如图 12-5 所示,就没有问题。对splitRunAndWait
中的tg.wait
的调用本身并不在一个隔离区域内,所以主线程和工作线程可以帮助处理不同的隔离区域,然后在它们完成时转移到其他区域。
但是如果我们把我们的主函数改成图 12-6 中的那个会怎么样呢?
图 12-6
在task_group tg
上调用run
和wait
的函数。对run
的调用是从一个隔离区域内发出的。
现在,对splitRunAndWait
的调用分别在不同的隔离区域内进行,随后对tg.wait
的调用在这些隔离区域内进行。每个调用tg.wait
的线程必须等到它的tg
结束,但不能窃取任何属于它的tg
或任何其他task_group
的任务,因为那些任务是从不同的隔离区域产生的!如果M
足够大,我们可能会让所有的线程都等待调用tg.wait
,而没有线程执行任何相关的任务。所以我们的应用程序死锁了。
如果我们使用一个将产生和等待分开的接口,我们可以通过确保我们总是在产生任务的同一个隔离区域中等待来避免这个问题。例如,我们可以重写图 12-6 中的代码,将对run
的调用移到外部区域,如图 12-7 所示。
图 12-7
在task_group tg
上调用run
和wait
的函数。对run
和wait
的呼叫现在都是在隔离区域之外进行的。
现在,即使我们的主函数使用了并行循环和隔离,我们也不再有问题,因为每个调用tg.wait
的线程将能够执行来自其tg
的任务:
即使是安全的,隔离工作也不是免费的
除了潜在的死锁问题,从性能的角度来看,工作隔离也不是免费的,所以即使它可以安全使用,我们也需要明智地使用它。不在隔离区域中的线程可以在窃取时选择任何任务,这意味着它可以从受害线程的 deque 中快速弹出最旧的任务。如果受害者完全没有任务,它也可以立即挑选另一个受害者。然而,在隔离区域中产生的任务及其子任务被标记以识别它们所属的隔离区域。在隔离区域中执行的线程必须扫描所选择的牺牲者的队列,以找到属于其隔离区域的最老的任务——不是任何老的任务都可以。并且该线程仅在扫描了所有可用任务并且没有从其区域中找到任务之后,才知道牺牲线程是否没有来自其隔离区域的任务。只有到那时,它才会选择另一个受害者来偷东西。从隔离区域内部窃取的线程有更多的开销,因为它们需要更加小心!
使用任务竞技场进行隔离:一把双刃剑
工作隔离限制了线程在寻找工作时的选择。我们可以使用前面部分描述的isolate
函数来隔离工作,或者我们可以使用class task_arena
。与本章相关的class task_arena
接口子集如图 12-8 所示。
图 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
的任务。
图 12-9
最大并发数为2
的task_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
我们已经可以开始看到isolate
和class task_arena
之间的差异。的确,只有ta2
中的线程能够执行ta2
中的任务,所以存在工作隔离,但是我们也能够设置能够参与执行嵌套的parallel_for
的线程的最大数量。
图 12-10 更进一步,创建了两个任务竞技场,一个max_concurrency
为 2,另一个max_concurrency
为 6。然后用一个parallel_invoke
创建两个任务,一个执行ta2
中的parallel_for
,另一个执行ta6
中的parallel_for
。两个parallel_for
循环具有相同的迭代次数,并且每次迭代旋转相同的时间。
图 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
这是使用isolate
和task_arena
创建隔离的关键区别。当使用task_arena
时,我们几乎总是更关心控制参与执行任务的线程,而不是隔离本身。隔离不是为了正确性,而是为了性能。显式的task_arena
是一把双刃剑——它让我们控制参与工作的线程,但也在它们之间筑起了一道高墙。当一个线程离开由isolate
创建的隔离区域时,它可以自由地参与执行它所在领域的任何其他任务。当一个线程在一个显式的task_arena
中没有工作可做时,它必须返回到全局线程池,然后找到另一个有工作可做并且有空位的地方。
注意
我们只是提供了一个关键的经验法则:使用isolate
主要是为了帮助正确性;使用task_arenas
主要是为了性能。
让我们再次考虑图 12-10 中的例子。我们在task_arena ta6
中创造了更多的插槽。结果,ta6
的parallel_for
比ta2
的parallel_for
完成得快多了。但是在ta6
中的工作完成后,分配给那个 arena 的线程返回到全局线程池。他们现在很闲,但无法帮助完成ta2
中的工作——竞技场只有两个线程槽,而且已经满了!
抽象非常强大,但是它在线程之间建立的高墙限制了它的实际应用。第十一章更详细地讨论了如何将class task_arena
与class task_scheduler_init
和class global_control
一起使用,以控制 TBB 应用中特定并行算法可用的线程数量。第二十章展示了我们如何使用task_arena
对象在非统一内存访问(NUMA)平台中的特定内核上划分工作和调度工作,以针对数据局部性进行调优。在这两章中,我们会看到task_arena
非常有用,但也有缺点。
不要试图使用task_arenas
来创建正确的工作隔离
在第 11 和 20 章描述的特定用例中,线程的数量甚至它们在特定内核上的位置都受到严格控制,因此我们希望在不同的领域拥有不同的线程。然而在一般情况下,需要task_arena
对象来管理和迁移线程只会产生开销。
作为一个例子,让我们再看一组嵌套的parallel_for
循环,但是现在没有正确性问题。我们可以在图 12-11 中看到代码和可能的任务树。如果我们执行这组循环,那么所有的任务都将出现在同一个任务舞台上。当我们在上一节中使用isolate
时,所有的任务仍然保存在同一个竞技场中,但是线程在窃取任务之前通过检查任务来隔离自己,以确保它们被允许根据隔离约束来获取任务。
图 12-11
两个嵌套的parallel_for
循环的例子:(a)源代码和(b)任务树
现在,让我们修改这个简单的嵌套循环示例,使用显式 task arena 对象创建隔离。如果我们想让每个在外循环中执行迭代的线程只执行自己内循环中的任务,这可以通过使用图 12-4 中的isolate
轻松实现,我们可以在每个外体中创建本地nested
显式task_arena
实例,如图 12-12(a) 和图 12-12(b) 所示。
图 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
。
开放存取本章根据知识共享署名-非商业-非专用 4.0 国际许可协议(http://Creative Commons . org/licenses/by-NC-nd/4.0/)的条款进行许可,该协议允许以任何媒体或格式进行任何非商业使用、共享、分发和复制,只要您适当注明原作者和来源,提供知识共享许可协议的链接,并指出您是否修改了许可材料。根据本许可证,您无权共享从本章或其部分内容派生的改编材料。
本章中的图像或其他第三方材料包含在该章的知识共享许可中,除非该材料的信用额度中另有说明。如果材料未包含在本章的知识共享许可中,并且您的预期用途不被法定法规允许或超出了允许的用途,您将需要直接从版权所有者处获得许可。
十三、创建线程到内核和任务到线程的亲和性
当使用线程构建模块库开发并行应用时,我们通过使用高级执行接口或低级 API 来创建任务。这些任务由 TBB 库使用工作窃取的方式调度到软件线程上。这些软件线程由操作系统(OS)调度到平台的核心(硬件线程)上。在这一章中,我们将讨论 TBB 的一些特性,这些特性让我们能够影响操作系统和 TBB 做出的调度选择。当我们想要影响操作系统,以便它将软件线程调度到特定的内核上时,就会使用线程到内核的亲和性。当我们想要影响 TBB 调度程序,以便它将任务调度到特定的软件线程上时,就使用任务到线程的亲和性。根据我们想要达到的目标,我们可能对一种或另一种亲和力感兴趣,或者对两者的结合感兴趣。
创造亲和力有不同的动机。最常见的动机之一是利用数据局部性。正如我们在本书中反复提到的,数据局部性会对并行应用程序的性能产生巨大的影响。TBB 库、它的高级执行接口、它的工作窃取调度器和它的并发容器都是在考虑了本地性的情况下设计的。对于许多应用程序来说,使用这些特性可以在不进行任何手动调整的情况下获得良好的性能。但是,有时我们需要提供提示或完全掌握自己的事情,以便 TBB 和操作系统中的调度程序更好地调度数据附近的工作。除了数据局部性之外,当使用异构系统时,当内核的能力不同时,或者当软件线程具有不同的属性时,例如更高或更低的优先级时,我们也可能对亲和性感兴趣。
在第十六章中,介绍了 TBB 并行算法揭示的数据局部性的高级特性。在第十七章中,我们将讨论 TBB 流程图中调整缓存和内存使用的特性。在第二十章中,我们展示了如何使用 TBB 库的特性来调优非一致内存访问(NUMA)架构。对于许多读者来说,这些章节中的信息将足以完成他们需要执行的特定任务,以调整他们的应用程序。在这一章中,我们将重点关注由 TBB 调度程序和任务提供的底层基础支持,这些任务有时由这些章节中描述的高级功能进行抽象,有时直接在这些章节中使用以创建关联性。
创建线程到内核的关联性
所有主流操作系统都提供了允许用户设置软件线程亲缘关系的接口,包括 Linux 上的pthread_setaffinity_np
或sched_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
类提供了一种观察线程何时开始或停止参与任务调度的方法。该类的接口如下所示:
为了使用这个类,我们创建了自己的类,它继承了task_scheduler_observer
并实现了on_scheduler_entry
和on_scheduler_exit
回调。当这个类的一个实例被构造并且它的observe
状态被设置为 true 时,每当一个主线程或工作线程进入或退出全局 TBB 任务调度器时,入口和出口函数将被调用。
最近对该类的扩展现在允许我们向构造器传递一个task_arena
。该扩展是 TBB 2019 Update 4 之前的预览功能,但现在完全支持。当传递一个task_arena
引用时,观察器将只接收进入和退出该特定领域的线程的回调:
图 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()
来查找调用线程被分配到的槽。因为我们知道默认领域中的插槽数量与逻辑核心的数量相同,所以我们将每个线程固定到与其插槽号相匹配的逻辑核心。
图 13-1
在 Linux 平台上使用task_scheduler_observer
将线程固定到内核
我们当然可以创建更复杂的方案来为线程分配逻辑内核。而且,虽然我们在图 13-1 中没有这么做,但是我们也可以为每个线程存储原始的 CPU 掩码,这样我们就可以在线程离开竞技场时恢复它。
正如我们在第二十章中所讨论的,我们可以使用task_scheduler_observer
类,结合显式task_arena
实例,来创建独立的线程组,这些线程组被限制在共享非统一内存访问(NUMA)系统(一个 NUMA 节点)中相同本地内存库的内核上。如果我们还控制数据放置,我们可以通过将工作放到数据所在的 NUMA 节点的舞台上来大大提高性能。详见第二十章。
我们应该始终记住,如果我们使用线程到内核的亲和性,我们会阻止操作系统将线程从超额预订的内核迁移到使用率较低的内核,因为它试图优化系统利用率。如果我们在生产应用中这样做,我们需要确保不会降低多道程序的性能!正如我们将多次提到的,只有专门运行单个应用程序的系统才有可能拥有限制动态迁移的环境。
创建任务到线程的关联性
因为我们在 TBB 使用任务来表达我们的并行工作,所以创建线程到内核的亲和性,正如我们在上一节中所描述的,只是难题的一部分。如果我们将线程固定到内核上,我们可能不会得到太多好处,但是会让我们的任务被工作窃取随机移动!
当使用第十章中介绍的低级 TBB 任务接口时,我们可以提供一些提示,告诉 TBB 调度器应该在特定的 arena 槽中的线程上执行一个任务。因为我们可能会尽可能使用更高级的算法和任务接口,例如parallel_for
、task_group
和流程图,但是我们很少直接使用这些低级接口。第十六章展示了affinity_partitioner
和static_partitioner
类如何与 TBB 循环算法一起使用,从而在不求助于这些低级接口的情况下创建亲缘关系。类似地,第十七章讨论了影响亲和力的 TBB 流图的特征。
因此,虽然任务到线程的亲和性是在低级任务类中公开的,但我们将通过高级抽象几乎专门使用这一特性。因此,使用我们在本节中描述的接口是留给 TBB 专家的,他们使用最底层的任务接口编写自己的算法。如果您是这样的专家,或者想更深入地了解高级接口是如何实现亲和力的,请继续阅读这一部分。
图 13-2 显示了 TBB task
类提供的函数和类型,我们用它们来提供相似性提示。
图 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
中。
图 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 )。但是实际的任务保留在产生它的线程的本地队列中。任务分派器仅在其本地队列中的工作耗尽时检查亲和邮箱,如第九章中的任务分派循环所示。因此,如果一个线程没有足够快地检查它的相似性邮箱,另一个线程可能会先窃取或执行它的任务。
图 13-4
相似性邮箱保存对任务的引用,该任务保留在产生该任务的线程的本地队列中
为了证明这一点,我们可以在我们的小例子中改变任务关联性的分配方式,如图 13-5 所示。现在,愚蠢的是,我们将所有的亲缘关系都设置到了同一个槽位,即a[2]
中记录的那个槽位。
图 13-5
首先运行不同任务组的功能,有时记录相似性,有时设置相似性。还显示了一个输出示例。
如果 TBB 调度器接受我们的相似性请求,将会有很大的负载不平衡,因为我们已经要求它将所有的工作发送到同一个工作线程。但是如果我们执行这个新版本的示例,我们会看到:
因为 affinity 只是一个提示,所以其他空闲线程仍然会找到任务,在槽a[2]
中的线程能够清空其 affinity 邮箱之前,从主线程的本地队列中窃取它们。事实上,只有第一个产生的任务id==0
被线程在先前记录在a[2]
中的槽中执行。因此,我们仍然看到我们的任务分布在所有八个线程上。
TBB 库忽略了我们的请求,而是避免了将所有这些任务发送到同一个线程所造成的负载不平衡。这种弱亲缘关系在实践中是有用的,因为它让我们交流亲缘关系,即应该提高性能,但它仍然允许库进行调整,以便我们不会无意中造成很大的负载不平衡。
虽然我们可以直接使用这些任务接口,但我们在第十六章中看到,循环算法提供了一个简化的抽象,affinity_partitioner
幸运的是,它对我们隐藏了这些底层细节。
我们应该何时以及如何使用 TBB 亲和力特征?
只有当我们在专用系统上调优以获得绝对最佳的性能时,我们才应该使用task_scheduler_observer
对象来创建线程到内核的亲和性。否则,我们应该让操作系统去做它的工作,并从全局的角度来调度它认为合适的线程。如果我们选择将线程绑定到内核,我们应该仔细权衡将这种灵活性从操作系统中移除的潜在影响,尤其是如果我们的应用程序运行在多程序环境中。
对于任务到线程的亲和性,我们通常希望使用高级接口,比如第十六章中描述的affinity_partitioner
。affinity_partitioner
使用本章描述的特性来跟踪任务的执行位置,并向 TBB 调度程序提供提示,以便在循环的后续执行中重放分区。它还跟踪更改以保持提示是最新的。
因为 TBB 任务关联性只是调度器提示,误用这些接口的潜在影响要小得多——所以我们在使用任务关联性时不需要那么小心。事实上,我们应该被鼓励去尝试任务相似性,特别是通过更高层次的接口,作为调整我们的应用程序的正常部分。
摘要
在本章中,我们讨论了如何在我们的 TBB 应用中创建线程到内核和任务到线程的亲和性。虽然 TBB 没有提供一个接口来处理设置线程到内核亲缘关系的机制,但它的class task_scheduler_observer
提供了一个回调机制,允许我们插入必要的调用到我们自己的特定于操作系统的或可移植的库,这些库分配亲缘关系。因为 TBB 偷工减料调度程序随机地将任务分配给软件线程,线程与内核的亲和性本身并不总是足够的。因此,我们也讨论了 TBB 的class task
中的接口,它让我们向 TBB 调度器提供关于我们希望任务被调度到哪个软件线程上的相似性提示。我们注意到我们很可能不会直接使用这些接口,而是使用第 16 和 17 章中描述的更高级接口。对于有兴趣了解这些低级接口的读者,我们提供了一些例子,展示了如何使用note_affinity
和set_affinity
函数为使用低级 TBB 任务接口的代码实现任务到线程的相似性。
就像 TBB 库的许多优化特性一样,需要小心使用相似性。不正确地使用线程到内核的关联性会限制操作系统平衡负载的能力,从而显著降低性能。使用任务到线程的相似性提示,仅仅是 TBB 调度器可以忽略的提示,如果不明智地使用,可能会对性能产生负面影响,但影响要小得多。
更多信息
-
Posix 设置/获取 CPU 对一个线程的亲和力,
http://man7.org/linux/man-pages/man3/pthread_setaffinity_np.3.html
-
SetThreadAffinityMask 函数,
https://docs.microsoft.com/en-us/windows/desktop/api/winbase/nf-winbase-setthreadaffinitymask
-
可移植硬件位置(hwloc),
www.open-mpi.org/projects/hwloc/
开放存取本章根据知识共享署名-非商业-非专用 4.0 国际许可协议(http://Creative Commons . org/licenses/by-NC-nd/4.0/)的条款进行许可,该协议允许以任何媒体或格式进行任何非商业使用、共享、分发和复制,只要您适当注明原作者和来源,提供知识共享许可协议的链接,并指出您是否修改了许可材料。根据本许可证,您无权共享从本章或其部分内容派生的改编材料。
本章中的图像或其他第三方材料包含在该章的知识共享许可中,除非该材料的信用额度中另有说明。如果材料未包含在本章的知识共享许可中,并且您的预期用途不被法定法规允许或超出了允许的用途,您将需要直接从版权所有者处获得许可。
十四、使用任务优先级
线程构建模块调度程序不是实时调度程序,因此不适合在硬实时系统中使用。在实时系统中,可以给任务一个必须完成的截止日期,如果错过了截止日期,任务的有用性就会降低。在硬实时系统中,错过最后期限会导致整个系统失败。在软实时系统中,错过截止时间并不是灾难性的,但会导致服务质量下降。TBB 图书馆不支持给任务分配期限,但是它支持任务优先级。这些优先级可能在具有软实时需求的应用中有用。无论它们是否足够,都需要了解应用程序的软实时需求以及 TBB 任务和任务优先级的属性。
除了软实时使用,任务优先级还可以有其他用途。例如,我们可能希望优先处理一些任务,因为这样做会提高性能或响应能力。也许我们想让释放内存的任务优先于分配内存的任务,以便减少应用程序的内存占用。或者,我们希望将接触缓存中已有数据的任务优先于将新数据加载到缓存中的任务。
在本章中,我们将描述 TBB 任务和 TBB 任务调度程序所支持的任务优先级。考虑将 TBB 用于软实时应用程序的读者可以使用这些信息来确定 TBB 是否足以满足他们的要求。如果需要实现受益于任务优先级的性能优化,其他读者可能会发现这些信息很有用。
支持 TBB 任务类中的非抢占式优先级
就像在第十三章中描述的对任务关联性的支持一样,TBB 对优先级的支持是通过低级任务类中的函数来实现的。TBB 图书馆定义了三个优先级:priority_normal
、priority_low
、priority_high
,如图 14-1 所示。
图 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
分配给一个新分配的根任务:
task_group_context
也是 TBB 高级算法和 TBB 流图的可选参数,例如:
我们可以在分配期间在任务级别分配组,也可以通过更高级的接口,例如 TBB 算法和流程图。还有其他的抽象,比如task_group
,让我们为了执行的目的对任务进行分组。task_group_context
组的目的是支持取消、异常处理和优先级。
当我们使用task::enqueue
函数来提供一个优先级时,这个优先级只影响单个任务,并且以后不能改变。当我们给一组任务分配一个优先级时,这个优先级会影响组中的所有任务,并且这个优先级可以在任何时候通过调用task::set_group_priority
或task_group_context::set_priority
来改变。
TBB 调度器跟踪就绪任务的最高优先级,包括排队的和产生的任务,并推迟(除了前面的警告)较低优先级任务的执行,直到所有较高优先级任务都被执行。默认情况下,所有任务和任务组都是用priority_normal
创建的。
两个小例子
图 14-2 显示了一个例子,它在一个有 P 个逻辑内核的平台上排列了 25 个任务。每个任务在给定的持续时间内积极地旋转。task_priority
函数中的第一个任务以正常优先级排队,并被设置为旋转大约 500 毫秒。然后,函数中的 for 循环创建 P 个低优先级、P 个普通优先级和 P 个高优先级任务,每个任务都将活跃地旋转大约 10 毫秒。当每个任务执行时,它会将一条消息记录到线程本地缓冲区中。高优先级任务id
以H
为前缀,普通任务id
以N
为前缀,低优先级任务id
以L
为前缀。在函数结束时,打印所有线程本地缓冲区,提供参与线程执行任务的顺序。这个例子的完整实现可以在 Github 库中找到。
图 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 显示了使用两个本机线程t0
和t1
并行执行两个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
。
图 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_group
和concurrent_priority_queue
的例子。当一项工作需要完成时,采取两个动作:(1)将工作的描述推入共享队列,以及(2)在task_group
中产生一个包装器任务,它将弹出并执行共享队列中的一个项目。结果是,每个工作项只产生一个任务——但是直到任务执行后才确定任务将处理的具体工作项。
图 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 月。
开放存取本章根据知识共享署名-非商业-非专用 4.0 国际许可协议(http://Creative Commons . org/licenses/by-NC-nd/4.0/)的条款进行许可,该协议允许以任何媒体或格式进行任何非商业使用、共享、分发和复制,只要您适当注明原作者和来源,提供知识共享许可协议的链接,并指出您是否修改了许可材料。根据本许可证,您无权共享从本章或其部分内容派生的改编材料。
本章中的图像或其他第三方材料包含在本章的知识共享许可中,除非在材料的信用额度中另有说明。如果材料不包括在本章的知识共享许可中,并且您的预期使用不被法律法规允许或超出了允许的使用范围,您将需要直接从版权所有者处获得许可。**
十五、取消和异常处理
或多或少,我们都会被运行时错误所困扰,无论是在顺序开发还是并行开发中。为了减轻痛苦,我们学会了使用错误代码或更高级的替代方法(如异常处理)来捕获它们。像大多数面向对象语言一样,C++ 支持异常处理,当方便地使用时,可以开发健壮的应用程序。现在,考虑到 TBB 在 C++ 的基础上增加了基于任务的并行性,开发人员期望异常处理得到很好的支持是完全可以理解的。正如我们将在本章中看到的,异常处理在 TBB 中确实得到了很好的自动支持。这意味着在出现错误的情况下,我们的代码可以求助于一个异常处理程序(如果有的话),否则就终止整个工作。考虑到这一点,在 TBB 实施支持当然不简单
-
异常可以在由多个线程执行的任务中抛出。
-
为了终止抛出异常的工作,必须实现任务的取消。
-
必须保持 TBB 的可组合性。
-
如果没有异常发生,异常管理不应该影响性能。
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 所示。
图 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 所示。
图 15-2
图 15-1 中代码的替代实现
在这段代码中,我们看到一个TGC
,tg,
被创建并作为parallel_for
的最后一个参数传递,还被用来调用tg.cancel_group_execution()
(现在使用了task_group_context class
的一个成员函数)。
注意图 15-1 和 15-2 的编码完全相同。可选的TGC
参数tg
,作为parallel_for
的最后一个参数通过,只是为更详细的开发打开了大门。例如,假设我们也将同一个TGC
变量tg
传递给我们在并行线程中启动的parallel_pipeline
。现在,在parallel_for
或parallel_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
。但是如果任务已经拥有了控制权,那么它就拥有了控制权,直到它屈尊将控制权归还给调度程序。但是,如果我们还想取消正在运行的任务,每个任务可以使用以下两种方法之一来共享取消状态:
下一个问题:新任务分配给哪个TGC
?当然,我们有设备来完全控制这种映射,但是也有一个默认的行为是值得了解的。首先,我们介绍如何手动将任务映射到一个TGC
中。
TGC 的明确分配
正如我们所见,我们可以创建TGC
对象,并将它们传递给高级并行算法(parallel_for,...
)和低级任务 API ( allocate_root()
)。请记住,在第十章中,我们还介绍了作为中级 API 的task_group
类,用于轻松创建共享TGC
的任务,这些任务可以通过单个动作同时取消或分配优先级。使用同一个task_group::run()
成员函数发起的所有任务将属于同一个TGC
,因此组中的一个任务可以取消整个帮派。
作为一个例子,考虑图 15-3 的代码,其中我们重写了一个data
向量中“隐藏”的给定值的并行搜索,并得到它存储的索引。这一次,我们使用手动实现的分而治之的方法,该方法使用了task_group
特性(parallel_for
方法实际上正在做一些类似的事情,即使我们没有看到)。
图 15-3
使用task_group
类手动实现并行搜索
为了方便起见,向量data
、结果索引myindex
和task_group
、g
都是全局变量。这段代码递归地将搜索空间一分为二,直到某个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
类实现这一点,我们只需要插入:
在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_for
、parallel_reduce
、parallel_do
、pipeline
、流程图等)时。),现在标记为“ bound ”的新TGC,
被隐式地创建,用于将在该嵌套算法中协作的新任务。因此,这个TGC
是一个绑定到独立父TGC
的子。 -
如果并行算法的任务调用嵌套的并行算法,则为这个新算法创建新的绑定子代
TGC
,其中父代现在是调用任务的TGC
。
图 15-4 中描述了一个由假想的 TBB 码自动构建的TGC
树林的例子。
图 15-4
运行假想的 TBB 代码时自动创建的TGC
树森林
在我们假设的 TBB 代码中,用户想要嵌套几个 TBB 算法,但是对TGC
s 一无所知,所以他只是调用这些算法,而没有传递可选的显式的TGC
对象。在一个主线程中,有一个对parallel_invoke
的调用,它自动初始化调度程序,创建一个竞技场和第一个隔离的TGC
、A
。然后,在parallel_invoke
中,创建了两个 TBB 算法,一个流图和一个pipeline
。对于这些算法中的每一个,自动创建一个新的TGC
、B
和C
,并绑定到A
。在其中一个流程图节点中,创建了一个task_group
,并且在不同的流程图节点中实例化了一个parallel_for
。这导致两个新创建的TGC
、D
和E
,它们被绑定到B
。这就形成了我们的TGC
森林的第一棵树,它有一个孤立的根,所有其他的TGCs
都绑定在这里,也就是说,它们有一个父树。第二棵树构建在一个不同的主线程中,它创建了一个只有两个并行范围的parallel_for
,并且为每个范围调用一个嵌套的parallel_for
。还是那句话,树根是一个孤立的TGC
、F
,其他的TGCs
、G
和H
,都是绑定的。请注意,用户只是编写了 TBB 代码,将一些 TBB 算法嵌套到其他 TBB 算法中。是 TBB 机器为我们创造了TGC
s 的森林。不要忘记任务:有几个任务共享每个TGC
。
现在,如果任务被取消会发生什么?别紧张。规则是包含这个任务的整个TGC
被取消,但是取消也向下传播。例如,如果我们取消了流程图的一个任务(TGC B
),我们也会取消task_group
( TGC D
)和parallel_for
( TGC E
),如图 15-5 所示。这是有意义的:我们正在取消流程图,以及从那里创建的一切。这个例子有些做作,因为可能很难找到这种算法嵌套的实际应用。然而,它说明了不同的TGC
是如何自动链接在一起,以处理被大肆吹嘘的 TBB 的可组合性。
图 15-5
从属于TGC B
的任务中调用取消
但是等等,我们可能想要取消流图和task_group
,但是保持parallel_for
( TGC E
)的活力。好吧,这也可以通过手动创建一个隔离的TGC
对象并将其作为 parallel for 的最后一个参数来传递。为此,我们可以编写类似于图 15-6 的代码,其中流程图g
的function_node
利用了这种可能性。
图 15-6
从TGC
的树中分离嵌套算法的替代方法
隔离的TGC
对象TGC_E
在堆栈上创建,并作为最后一个参数传递给parallel_for
。现在,如图 15-7 所示,即使流程图的一个任务取消了它的TGC B
,取消向下传播到TGC D
,但是不能到达TGC E
,因为它已经从树中分离出来创建了。
图 15-7
TGC E
现在被隔离,不会被取消
更准确地说,孤立的TGC E
现在可以是我们的TGC
s 森林中另一棵树的根,因为它是一个孤立的TGC
,并且它可以是为更深层次的嵌套算法创建的新TGC
的父代。我们将在下一节看到一个这样的例子。
总的来说,如果我们嵌套 TBB 算法而没有显式地传递一个TGC
对象给它们,那么默认的TGC
s 的森林将会在取消的情况下产生预期的行为。然而,通过创建必要数量的TGC
对象并将它们传递给期望的算法,这种行为可以由我们随意控制。例如,我们可以创建一个单独的TGC
、A
,并将其传递给我们假设的 TBB 示例的第一个线程中调用的所有并行算法。在这种情况下,所有算法中协作的所有任务都将属于那个TGC A
,如图 15-8 所示。如果现在流程图的一个任务被取消,不仅嵌套的task_group
和parallel_for
算法也被取消,所有共享TGC A
的算法也被取消。
图 15-8
在修改了我们假设的 TBB 代码之后,我们将单个 TGC A 传递给所有的并行算法
关于取消的最后一点,我们想强调的是,有效地跟踪TGC
的森林以及它们是如何被链接起来的,是一件非常具有挑战性的事情。感兴趣的读者可以看看 Andrey Marochko 和 Alexey Kukanov 的论文(参见“更多信息”部分),其中他们详细阐述了实现决策和内部细节。主要的收获是,如果不需要取消,要非常小心地确保TGC
记账不会影响性能。
TBB 的异常处理
注意
如果对 C++ 异常不太熟悉,这里有一个例子可以帮助说明基本原理:
运行这段代码后的输出是
Re-throwing value: 5Value caught: 5
正如我们所看到的,第一个 try 块包含一个嵌套的 try catch。这个异常抛出一个值为 5 的整数。因为 catch 块匹配类型,所以这段代码成为异常处理程序。这里,我们只打印接收到的值,并向上重新抛出异常。在外层有两个 catch 块,但是第一个被执行,因为参数类型与抛出值的类型相匹配。外部级别中的第二个 catch 会收到一个省略号(…),因此如果异常具有前面 catch 函数链中未考虑的类型,它将成为实际的处理程序。例如,如果我们抛出 5.0 而不是 5,输出消息将是“发生了异常”
既然我们已经了解了取消是支持 TBB 异常管理的关键机制,那么让我们来看看问题的实质。我们的目标是掌握执行异常的防弹代码的开发,如图 15-9 所示。
图 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::exception
、tbb::tbb_exception
或其他)的附加细节。然而,由于现在很难找到一个不支持 C++11 的编译器,我们不会额外关注这个 TBB 向后兼容特性。
定制我们自己的 TBB 例外
TBB 库已经提供了一些预定义的异常类,它们在图 B-77 的表格中列出。
但是,在某些情况下,衍生出我们自己特定的 TBB 例外是一种很好的做法。为此,我们可以使用抽象类tbb::tbb_exception
,如图 15-10 所示。这个抽象类实际上是一个接口,因为它声明了我们被迫在派生类中定义的五个纯虚函数。
图 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
来构建我们自己的异常,这样更容易,也更好。在内部,这个类模板为我们实现了所需的虚函数。之前描述的五个虚函数现在是常规的成员函数,我们可以选择是否覆盖它们。然而,正如我们在签名摘录中看到的,还有其他可用的功能:
将举例说明movable_exception
构造器和data()
成员函数。假设除以 0 是一个我们想要明确捕捉的异常事件。在图 15-11 中,我们展示了如何在类模板tbb::movable_exception
的帮助下创建自己的异常。
图 15-11
配置我们自己的可移动异常的方便选择
我们用我们希望与异常一起移动的数据创建我们的自定义类div_ex
。在这种情况下,有效载荷是整数it
,它将存储被 0 除的位置。现在我们能够创建一个对象,movable_exception
类的de
,用模板参数div_ex
实例化,如我们在下面的代码行中所做的:
tbb::movable_exception<div_ex> de{div_ex{i}};
我们可以看到,我们传递了一个构造器div_ex
,div_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 块:
因为这个异常处理程序只有在接收到movable_exception<div_ex>
时才会被执行,而这只有在被0
除的情况下才会发生。
放在一起:可组合性、取消和异常处理
为了结束这一章,让我们用最后一个例子回到 TBB 的可组合性方面。在图 15-12 中,我们有一个代码片段显示了一个parallel_for
,它将遍历矩阵Data
的行,如果不是因为它在第一次迭代中抛出了一个异常(实际上是字符串“oops”)。!对于每一行,嵌套的parallel_for
也应该并行遍历Data
的列。
图 15-12
嵌套在引发异常的外部parallel_for
中的parallel_for
假设四个不同的任务正在运行外层循环的四个不同迭代i
,并调用内层循环parallel_for
。在那种情况下,我们可能会得到一个类似于图 15-13 的TGC
树。
图 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
的最后一个参数传递,取消对该行的注释:
我们得到了TGC
的不同配置,如图 15-14 所示。
图 15-14
TGC 的不同配置
在这种新的情况下,异常引发了抛出它的TGC
、TGC A
的取消,但是没有TGC A
的子节点可以取消。现在,如果我们检查数组data
的值,我们将看到行要么全部是真元素,要么全部是假元素,而不是像前一种情况那样是混合元素。这是因为一旦内部循环开始用真值设置一行,就不会中途取消。
在更一般的情况下,如果我们可以这样说图 15-4 中的 TGC 树的森林,如果一个嵌套算法抛出一个在任何级别都没有被捕获的异常,会发生什么呢?例如,让我们假设在图 15-15 的TGC
s 的树中,在流图(TGC B
)内部抛出了一个异常。
图 15-15
嵌套 TBB 算法中引发的异常的影响
当然,TGC B
和后代TGCs D
和E
也被取消了。我们知道。但是异常向上传播,并且如果在那个级别它也没有被捕获,它也将引发TGC A
中任务的取消,并且因为取消向下传播,TGC C
也死亡。太好了。这是预期的行为:一个异常,不管它被抛出到什么级别,都可以优雅地抛弃整个并行算法(就像它抛弃串行算法一样)。我们可以通过在期望的级别捕获异常或者通过在隔离的TGC
中配置所需的嵌套算法来防止取消链。是不是很整洁?
摘要
在本章中,我们看到取消 TBB 并行算法和使用异常处理来管理运行时错误是很简单的。如果我们采用默认行为,这两个特性都可以按预期的那样开箱即用。我们还讨论了 TBB 的一个重要特征,任务组上下文,TGC
。这个元素是 TBB 中取消和异常处理实现的关键,可以手动利用它来更好地控制这两个特性。我们开始讲述取消操作,解释一个任务如何取消它所属的整个TGC
。然后我们回顾了如何手动设置任务映射到的TGC
,以及当开发人员没有指定映射时应用的规则。默认规则导致预期的行为:如果一个并行算法被取消,那么所有嵌套的并行算法也被取消。然后我们继续讨论异常处理。同样,TBB 异常的行为类似于顺序代码中的异常,尽管 TBB 的内部实现要复杂得多,因为由一个线程执行的一个任务中抛出的异常可能最终被另一个线程捕获。当编译器支持 C++11 特性时,可以在线程之间移动异常的精确副本,否则,在tbb::captured_exception
中捕获异常的摘要,以便可以在并行上下文中重新抛出。我们还描述了如何使用类模板tbb::movable_exception
配置我们自己的异常类。最后,我们通过阐述可组合性、取消和异常处理是如何相互作用的来结束这一章。
更多信息
以下是我们推荐的一些与本章相关的额外阅读材料:
-
A.Marochko 和 A. Kukanov,《英特尔线程构建模块任务调度器中的可组合并行基础》,《并行计算进展》,第 22 卷,2012 年。
-
Deb Haldar,15 大 C++ 异常处理错误以及如何避免它们。
www.acodersjourney.com/2016/08/top-15-c-exception-handling-mistakes-avoid/
。
开放存取本章根据知识共享署名-非商业-非专用 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
实现了并行版本的快速排序,并在数组大小(以及任务执行时间)低于截止阈值时将递归并行实现定向到串行实现:
当我们使用简单的循环算法时,比如parallel_for
、parallel_reduce
和parallel_scan
,它们的范围和划分器参数为我们提供了我们需要的控制。我们将在下一节更详细地讨论这些。
为循环选取范围和分割器
正如第二章所介绍的,一个范围代表一组递归可分的值——通常是一个循环的迭代空间。我们使用带有简单循环算法的范围:parallel_for
、parallel_reduce
、parallel_deterministic_reduce
和parallel_scan
。TBB 算法划分其范围,并使用 TBB 任务将算法的主体对象应用于这些子范围。与分割器相结合,范围提供了一种简单但强大的方法来表示迭代空间,并控制如何将它们划分为任务和分配给工作线程。这种划分可用于调整任务粒度和数据局部性。
要成为一个范围,一个类必须模拟如图 16-1 所示的范围概念。范围可以被复制,可以使用拆分构造器进行拆分,并且可以可选地提供比例拆分构造器。它还必须提供检查它是否为空或可分的方法,并提供一个布尔常量,如果它定义了比例分割构造器,则该常量为真。
图 16-1
范围概念
虽然我们可以定义自己的范围类型,但 TBB 库提供了如图 16-2 所示的阻塞范围,这将涵盖大多数情况。例如,我们可以用blocked_range2d<int, int> r(i_begin, i_end, j_begin, j_end )
来表示以下嵌套循环的迭代空间:
图 16-2
TBB 图书馆提供的封锁范围
对于感兴趣的读者,我们在本章末尾的“深入讨论”部分描述了如何定义一个自定义范围类型。
划分器概述
除了范围,TBB 算法还支持指定算法如何划分其范围的划分器。不同的分隔器类型如图 16-3 所示。
图 16-3
TBB 图书馆提供的隔板
一个simple_partitioner
用于递归划分一个范围,直到它的is_divisible
方法返回 false。对于被阻止的范围类型,这意味着该范围将被分割,直到其大小小于或等于其粒度。如果我们已经高度调整了我们的粒度(我们将在下一节讨论这个),我们希望使用一个simple_partitioner
,因为它确保最终的子范围符合提供的粒度。
一个auto_partitioner
使用一个动态算法来充分分割一个范围以平衡负载,但是它不一定像is_divisible
所允许的那样细分一个范围。当与 blocked range 类一起使用时,粒度仍然为最终块的大小提供了一个下限,但是由于auto_partitioner
可以决定使用更大的粒度,所以它就不那么重要了。因此,使用粒度为 1 并让auto_partitioner
决定最佳粒度通常是可以接受的。在 TBB 2019 中,parallel_for
、parallel_reduce
和parallel_scan
使用的默认划分器类型是粒度为 1 的auto_partitioner
。
A static_partitioner
尽可能均匀地在工作线程上分配范围,没有进一步负载平衡的可能性。工作分配和线程映射是确定性的,只取决于迭代次数、粒度和线程数量。在所有分区中,static_partitioner
的开销最低,因为它不做动态决策。使用static_partitioner
还可以改善缓存行为,因为调度模式将在同一个循环的执行中重复。然而,A static_partitioner
严重限制了负载平衡,因此需要谨慎使用。在“使用static_partitioner
”一节中,我们将重点介绍static_partitioner
的优点和缺点。
affinity_partitioner
结合了auto_partitioner
和static_partitioner
的优点,如果在相同的数据集上重新执行循环时重用相同的分割器对象,则可以提高缓存亲和力。与static_partitioner
一样,affinity_partitioner
最初创建一个统一的分布,但允许额外的负载平衡。它还记录了哪个线程执行了该范围的哪个块,并试图在后续执行中重新创建这种执行模式。如果一个数据集完全适合处理器的缓存,重复调度模式可以显著提高性能。
选择粒度(或不选择粒度)来管理任务粒度
在本章的开始,我们谈到了任务粒度的重要性。当我们使用阻塞范围类型时,我们应该总是高度调整我们的粒度,对吗?不一定。在使用阻塞范围时,选择正确的粒度可能极其重要——或者几乎无关紧要——这完全取决于所使用的划分器。
如果我们使用一个simple_partitioner
,粒度是传递给主体的范围大小的唯一决定因素。当使用simple_partitioner
时,范围被递归细分,直到is_divisible
返回 false。相比之下,所有其他划分器都有自己的内部算法来决定何时停止划分范围。选择 1 的粒度对于那些只使用is_divisible
作为下限的划分器来说已经足够了。
为了演示粒度对不同划分器的影响,我们可以使用一个简单的parallel_for
微基准测试,并改变循环中的迭代次数(N)、粒度、每次循环迭代的执行时间以及划分器。
图 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
更快地接近最大性能,因为需要更少的迭代来克服调度开销。
图 16-5
加速不同的分区类型和增加粒度。正在测试的循环中的总迭代次数是 2 18 == 262144
对于图 16-5 中显示的除simple_partitioner
之外的所有划分器类型,我们看到从粒度 1 到 4096 的最大性能。我们的平台有 8 个逻辑内核,因此我们需要一个小于或等于 2 18 /8 == 32,768 的粒度来为每个线程提供至少一个块;因此,在粒度达到 32768 之后,所有的划分器都开始变小。我们可能还会注意到,在粒度为 4096 的情况下,auto_partitioner
和affinity_partitioner
在所有图中都表现出性能下降。这是因为选择大粒度限制了这些算法的选择,干扰了它们完成自动划分的能力。
这个小实验证实了粒度对simple_partitioner
至关重要。我们可以使用一个simple_partitioner
来手动选择任务的大小,但是当我们这样做的时候,我们需要更准确地选择。
第二点是,当主体大小接近 1 us (10ns x 128 = 1.28 us)时,可以看到高效的执行,加速接近线性上限。这个结果加强了我们在本章前面介绍的经验法则!这并不奇怪,因为像这样的经验和实验首先是我们的经验法则的原因。
范围、分区器和数据缓存性能
范围和分区器可以通过启用缓存无关算法或启用缓存关联来提高数据缓存性能。当数据集太大而无法放入数据缓存时,缓存无关算法非常有用,但是如果使用分而治之的方法解决这个问题,就可以在算法中重用数据。相比之下,当数据集完全适合缓存时,缓存相似性非常有用。高速缓存关联用于将一个范围的相同部分重复调度到相同的处理器上,以便可以从相同的高速缓存中再次访问适合高速缓存的数据。
缓存无关算法
高速缓存不经意算法是一种不依赖于硬件高速缓存参数知识就能实现良好(甚至最佳)使用数据高速缓存的算法。该概念类似于循环平铺或循环分块,但不需要精确的平铺或分块大小。缓存无关算法通常递归地将问题分成越来越小的子问题。在某种程度上,这些小的子问题开始适合机器的缓存。递归细分可能会一直持续到尽可能小的大小,或者可能会有一个效率分界点,但这个分界点是与缓存大小相关的 而不是 ,并且通常会创建访问大小远低于任何合理缓存大小的数据的模式。
因为高速缓存无关算法对高速缓存性能一点也不感兴趣,我们已经听到了许多其他建议的名称,例如高速缓存不可知,因为这些算法针对它们遇到的任何高速缓存进行优化;和缓存偏执,因为他们假设可以有无限级缓存。但是缓存遗忘是文献中使用的名称,并且它已经被记住了。
这里,我们将使用矩阵转置作为一个算法示例,它可以从缓存无关的实现中获益。矩阵转置的非缓存无关串行实现如图 16-6 所示。
图 16-6
矩阵转置的串行实现
为了简单起见,让我们假设四个元素适合我们机器中的一个缓存行。图 16-7 显示了在 N×N 矩阵a
的前两行转置期间将被访问的缓存行。如果高速缓存足够大,它可以在第一行a
的转置期间保留在b
中访问的所有高速缓存行,而不需要在第二行a
的转置期间重新加载这些高速缓存行。但是如果它不够大,这些缓存线将需要重新加载——导致每次访问矩阵b
时缓存未命中。在图中,我们展示了一个 16×16 的阵列,但是想象一下如果它非常大。
图 16-7
转置矩阵a
的前两行时访问的缓存行。为简单起见,我们在每个高速缓存行中显示四个项目。
该算法的高速缓存无关实现减少了在相同高速缓存行或数据项的重复使用之间访问的数据量。如图 16-8 所示,如果我们在移动到矩阵a
的其他块之前,只专注于转置矩阵a
的一个小块,我们可以减少缓存行的数量,这些缓存行保存需要保留在缓存中的 b 的元素,以获得缓存行重用带来的性能提升。
图 16-8
一次转置一个块可以减少需要保留的缓存行的数量,从而有利于重用
图 16-9 显示了矩阵转置的缓存无关实现的串行实现。它沿i
和j
维度递归细分问题,并在范围低于阈值时使用串行 for 循环。
图 16-9
矩阵转置的串行高速缓存无关实现
因为实现在i
和j
方向的划分之间交替,矩阵a
使用图 16-10 所示的遍历模式转置,首先完成块 1,然后 2,然后 3,等等。如果gs
是 4,我们的缓存行大小是 4,我们在每个块内得到重用,如图 16-8 所示。但是,如果我们的缓存行是 8 项而不是 4 项(这对于实际系统来说更有可能),我们不仅可以在最小的块内重用,还可以跨块重用。例如,如果数据高速缓存可以保留在块 1 和块 2 期间加载的所有高速缓存行,则当转置块 3 和块 4 时,这些高速缓存行将被重用。
图 16-10
一种遍历模式,在移动到其他块之前计算a
的子块的转置
这就是缓存无关算法的真正威力——我们不需要确切知道内存层次结构的级别大小。随着子问题变得越来越小,它们在内存层次结构中的位置也越来越小,从而提高了每一级的重用性。
TBB 循环算法和 TBB 调度程序是专门为支持高速缓存无关算法而设计的。因此,我们可以使用图 16-11 所示的parallel_for
、blocked_range2d
和simple_partitioner
快速实现矩阵转置的缓存无关并行实现。我们使用一个blocked_range2d
,因为我们希望迭代空间被细分成二维块。我们使用simple_partitioner
,因为只有当块被细分为小于缓存大小时,我们才能从重用中获益;其他类型的分区器优化负载平衡,因此如果范围大小足以平衡负载,可以选择更大的范围大小。
图 16-11
矩阵转置的高速缓存无关并行实现,使用一个simple_partitioner
、一个blocked_range2d
和一个粒度(gs
)
图 16-12 显示了 TBB parallel_for
递归细分范围的方式创建了我们想要的缓存无关实现的相同块。TBB 调度器的深度优先工作和宽度优先窃取行为也意味着块将以类似于图 16-10 所示的顺序执行。
图 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 矩阵上实现的加速。
图 16-13
在我们的测试机上,对于 N=8192,使用不同粒度和划分器的加速比
矩阵转置受限于我们读写数据的速度——没有任何计算。从图 16-13 中我们可以看到,不管我们使用的粒度大小如何,我们的 1D blocked_range
并行实现比我们的简单串行实现性能更差。串行实现已经受到内存带宽的限制——添加额外的线程只会给已经不堪重负的内存子系统增加更多压力,而且于事无补。
我们的串行缓存忽略算法对内存访问进行了重新排序,减少了缓存未命中的数量。它明显优于简单版本。当我们在我们的并行实现中使用一个blocked_range2d
时,我们同样得到 2D 细分。但是正如我们在图 16-13 中看到的,只有当我们使用一个simple_partitioner
时,它才完全表现得像一个缓存无关的算法。事实上,我们的高速缓存无关并行算法通过一个blocked_range2d
和一个simple_partitioner
降低了内存层次的压力,现在使用多线程可以提高串行高速缓存无关实现的性能!
不是所有的问题都有缓存无关的解决方案,但是很多常见的问题都有。值得花时间研究问题,看看缓存无关的解决方案是否可行,是否值得。如果是这样,阻塞范围类型和simple_partitioner
将使得用 TBB 算法实现一个变得非常容易。
缓存相似性
缓存无关算法通过将具有数据局部性但不适合缓存的问题分解成适合缓存的较小问题来提高缓存性能。相比之下,高速缓存相似性解决了跨已经适合高速缓存的数据重复执行范围的问题。由于数据适合缓存,如果在后续执行中将相同的子范围分配给相同的处理器,则可以更快地访问缓存的数据。我们可以使用affinity_partitioner
或static_partitioner
来为 TBB 循环算法启用缓存关联。图 16-14 显示了一个简单的微基准,它为 1D 数组中的每个元素增加一个值。该函数接收对分割器的引用——我们需要接收分割器作为在affinity_partitioner
对象中记录历史的引用。
图 16-14
使用 TBB parallel_for
向 1D 数组的所有元素添加值的函数
为了查看缓存关联性的影响,我们可以重复执行这个函数,为N
发送相同的值,并发送相同的数组a
。当使用auto_partitioner
时,线程子范围的调度将随着调用的不同而不同。即使数组a
完全适合处理器的缓存,在随后的执行中,a
的相同区域可能不会落在同一个处理器上:
然而,如果我们使用一个affinity_partitioner
,TBB 库将记录任务调度,并使用相似性提示在每次执行时重新创建它(参见第十三章了解更多关于相似性提示的信息)。因为历史记录在分区器中,所以我们必须在后续执行中传递相同的分区器对象,而不能像使用auto_partitioner
那样简单地创建一个临时对象:
最后,我们还可以使用一个static_partitioner
来创建缓存亲缘关系。因为当我们使用static_partitioner
时调度是确定的,所以我们不需要为每次执行传递相同的 partitioner 对象:
我们在测试机上使用 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
假设所有的内核能力相同,并在它们之间均匀地分配工作。结果,过载的内核成为瓶颈,加速性能受到严重影响。
图 16-15
当一个额外的线程在后台执行自旋循环时,不同分区类型的加速和粒度的增加。每次迭代的时间被设置为 1 us。
图 16-16 显示了一个工作随着每次迭代而增加的循环。如果使用了一个static_partitioner
,得到最低迭代集的线程将比得到最高迭代集的不幸线程有更少的工作要做。
图 16-16
在每次迭代中工作量增加的循环
如果我们使用 N=1000 的每种划分器类型运行图 16-16 中的循环十次,我们会看到以下结果:
auto_partitioner = 0.629974 seconds
affinity_partitioner = 0.630518 seconds
static_partitioner = 1.18314 seconds
auto_partitioner
和affinity_partitioner
能够在线程间重新平衡负载,而static_partitioner
仍坚持其最初的统一但不公平的分配。
因此,static_partitioner
几乎只在高性能计算(HPC)应用中有用。这些应用程序运行在具有多个内核的系统上,并且通常以批处理模式运行,即一次运行一个应用程序。如果工作负载不需要 任何 的动态负载平衡,那么static_partitioner
将几乎总是优于其他划分器。不幸的是,平衡良好的工作负载和单用户、批处理模式的系统是例外,而不是规则。
限制调度程序以实现确定性
在第二章中,我们讨论了结合律和浮点类型。我们注意到浮点数的任何实现都是近似的,所以当我们依赖于结合性或交换性等属性时,并行性会导致不同的结果——这些结果不一定是错误的;他们只是不同而已。尽管如此,在归约的情况下,如果我们想确保在同一台机器上对相同的输入数据执行时得到相同的结果,TBB 提供了一个parallel_deterministic_reduce
算法。
正如我们可能猜测的那样,parallel_deterministic_reduce
只接受simple_partitioner
或static_partitioner
,因为子范围的数量对于这两种划分器类型都是确定的。无论有多少线程动态参与执行,任务如何映射到线程,在给定的机器上,parallel_deterministic_reduce
也总是执行相同的一组拆分和连接操作——而parallel_reduce
算法可能不会。结果是parallel_deterministic_reduce
在同一台机器上运行时总是返回相同的结果——但是牺牲了一些灵活性。
图 16-17 显示了使用parallel_reduce
( r-auto
、r-simple
、r-static
)和parallel_deterministic_reduce
( d-simple
、d-static
)实现时第二章中 pi 计算示例的加速。两者的最大加速是相似的;然而,auto_partitioner
对于parallel_reduce
来说表现很好,而这根本不是parallel_deterministic_reduce.
的选项。如果需要,我们可以实现我们的基准的确定性版本,但必须处理选择良好粒度的复杂性。
虽然parallel_deterministic_reduce
会有一些额外的开销,因为它必须执行所有的拆分和连接,但这种开销通常很小。更大的限制是我们不能使用任何自动为我们找到块大小的分割器。
图 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_order
、serial_out_of_order
或parallel
。使用serial_in_order
模式时,一个过滤器一次最多只能处理一个项目,并且必须按照第一个过滤器生成它们的顺序进行处理。一个serial_out_of_order
过滤器被允许以任何顺序执行项目。允许对不同的项目并行执行一个parallel
过滤器。我们将在本节的后面讨论这些不同的模式是如何限制性能的。
运行时,我们需要为 TBB 管道提供一个max_number_of_live_tokens
参数,该参数约束在任何给定时间允许流经管道的项目数量。
图 16-18 显示了我们将用来探索这些不同控件的微基准的结构。在图中,两个管道都显示有八个过滤器,但我们将在实验中改变这个数字。顶部管道的过滤器使用相同的执行mode,
,并且都有相同的spin_time
——所以这代表了一个非常平衡的管道。底部管道有一个比imbalance * spin_time
旋转的过滤器——我们将改变这个不平衡因子,看看不平衡对加速的影响。
图 16-18
平衡的管道微基准和不平衡的管道微基准
了解平衡的管道
让我们首先考虑一下我们对于任务大小的经验法则在管道中的应用情况。1 微秒的滤波器体足以减少开销吗?图 16-19 显示了在仅使用单个令牌的情况下,当输入 8000 个项目时,我们的平衡管道微基准测试的加速。显示了不同过滤器执行时间的结果。因为只有一个令牌,所以一次只允许一个项目流过管道。结果是管道的序列化执行(即使过滤器执行模式设置为并行)。
图 16-19
当在我们的测试机器上执行具有八个过滤器、一个令牌和 8000 个项目的平衡管道时,不同过滤器执行模式所看到的开销
与真正的串行执行相比,在真正的串行执行中,我们在 for 循环中执行适当数量的旋转,我们看到了将工作管理为 TBB 流水线的影响。在图 16-19 中,我们看到当spin_time
接近 1 微秒时,开销相当低,我们非常接近真正串行执行的执行时间。似乎我们的经验法则也适用于 TBB 管道!
现在,让我们看看过滤器的数量如何影响性能。在串行流水线中,并行性仅来自不同滤波器的重叠。在具有并行过滤器的流水线中,并行性也通过对不同的项目同时执行并行过滤器来获得。我们的目标平台支持八个线程,因此我们预计并行执行的加速比最多为 8。
图 16-20 显示了当令牌数设置为 8 时,我们的平衡管道微基准测试的加速。对于这两种串行模式,加速会随着滤波器数量的增加而增加。记住这一点很重要,因为串行流水线的加速不像 TBB 循环算法那样随数据集大小而变化。然而,包含所有并行过滤器的平衡流水线即使只有一个过滤器也具有 8 倍的加速比。这是因为 8000 个输入项可以在单个过滤器中并行处理——没有串行过滤器会成为瓶颈。
图 16-20
当执行具有 8 个令牌、8000 个项目和不断增加的过滤器数量的平衡管道时,不同的过滤器执行模式实现的加速。过滤器旋转 100 微秒。
在图 16-21 中,我们看到了使用八个过滤器但令牌数量不同时,我们的平衡流水线的加速。因为我们的平台有八个线程,如果我们的令牌少于八个,那么就没有足够的项目来保持所有线程的忙碌。一旦管道中至少有八个项目,所有线程都可以参与。将令牌数量增加到八个以上对性能几乎没有影响。
图 16-21
当执行具有八个过滤器、8000 个项目和不断增加的令牌数量的平衡管道时,不同过滤器执行模式实现的加速。过滤器旋转 100 微秒。
了解不平衡的管道
现在,让我们看看图 16-18 中不平衡管道的性能。在这个微基准测试中,除了一个过滤器旋转了spin_time * imbalance
秒之外,所有的过滤器都旋转了spin_time
秒。因此,当N
物品通过我们带有八个过滤器的不平衡管道时,处理这些物品所需的工作量为
在稳定状态下,串行流水线受到最慢串行级的限制。当不平衡滤波器以串行模式执行时,同一流水线的临界路径长度等于
图 16-22 显示了在我们的测试平台上使用不同的不平衡系数执行不平衡流水线的结果。我们还包括理论上的最大加速,标记为“工作/关键路径”,计算为
不出所料,图 16-22 显示串行流水线受到最慢滤波器的限制——测量结果接近我们的工作/关键路径长度计算预测。
图 16-22
当执行具有八个过滤器、8000 个项目和不同不平衡因子的不平衡管道时,不同过滤器执行模式实现的加速。七个过滤器旋转 100 微秒,其他的旋转imbalance * 100
微秒。
相比之下,图 16-22 中的并行管道不受最慢阶段的限制,因为 TBB 调度程序可以将最慢过滤器的执行与同一过滤器的其他调用重叠。您可能想知道将令牌数量增加到八个以上是否会有帮助,但在这种情况下,没有帮助。我们的测试系统只有八个线程,因此我们最多可以重叠最慢过滤器的八个实例。虽然在某些情况下,临时负载不平衡可以通过拥有比线程数量更多的令牌来消除,但在我们的微基准测试中,不平衡是一个常量,我们实际上受到关键路径长度和线程数量的限制,任何数量的额外令牌都不会改变这一点。
但是,在一些算法中,令牌数量不足会妨碍窃取工作的 TBB 调度程序的自动负载平衡功能。这种情况发生在各级不平衡,并且有一系列级使管道停止工作的时候。A. Navarro 等人证明了(参见本章末尾的“更多信息”部分),如果使用正确的令牌数进行适当配置,在 TBB 中实现的流水线算法可以产生最佳性能。她设计了一个基于排队论的分析模型,有助于找到这个关键参数。这篇论文的一个主要观点是,当令牌的数量足够大时,TBB 中的工作窃取模拟了一个能够为所有线程提供服务的全局队列(在排队论中,一个具有由所有资源服务的单个全局队列的理论集中式系统是已知的理想情况)。然而,在现实中,当一个全局单队列由大量线程服务时,它会出现争用。TBB 实现的根本优势在于,它采用了分布式解决方案,每个线程一个队列,由于工作窃取调度器的作用,该队列表现为一个全局队列。也就是说,分散式 TBB 实现像理想的集中式系统一样运行,但是没有集中式系统的瓶颈。
管道和数据局部性以及线程关联性
对于 TBB 循环算法,我们使用阻塞范围类型affinity_partitioner
和static_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
0
和f
1
满足前面提到的条件,这种情况永远不会发生。因此,TBB 管道偏向于在管道开始注入更多物品之前完成已经在飞行中的物品;这种行为不仅利用了数据局部性,而且通过减少串行筛选器所需的队列大小,使用了更少的内存。
不幸的是,TBB 管道过滤器不支持相似性提示。没有办法暗示我们想要特定的过滤器在特定的工作线程上执行。但是,也许令人惊讶的是,有一个硬亲和机制。然而,使用thread_bound_filter
需要使用更容易出错、类型不安全的tbb::pipeline
接口,我们将在下一节“深入讨论”中对此进行描述
杂草深处
本节涵盖了一些 TBB 用户很少使用的功能,但在需要时,它们会非常有用。如果您需要创建自己的范围类型或在 TBB 管道中使用thread_bound_filter
,您可以选择跳过这一节,按需阅读。或者,如果你真的想尽可能多地了解 TBB,请继续读下去!
打造您自己的产品系列
正如本章前面提到的,被阻止的范围类型包含了最常见的情况。在我们使用 TBB 的这些年里,我们个人只遇到过少数几个实施我们自己的 Range 类型有意义的情况。但是如果我们需要,我们可以通过实现模拟图 16-1 中描述的范围概念的类来创建我们自己的范围类型。
作为一个有用但非典型的范围类型的例子,我们可以再次回顾快速排序算法,如图 16-23 所示。
图 16-23
串行快速排序的实现
在这里,我们将并行化快速排序,而不是作为一个递归算法,而是使用一个parallel_for
和我们自己的自定义ShuffleRange.
我们的pforQuicksort
实现如图 16-24 所示。
图 16-24
使用一个parallel_for
和一个实现范围的自定义ShuffleRange
实现并行快速排序
在图 16-24 中,我们可以看到parallel_for
体λ表达式是基础情况,这里我们称之为serialQuicksort
。我们还使用了一个simple_partitioner
,这意味着我们的范围将被递归分割,直到它从它的is_divisible
方法返回 false。因此,快速排序的所有洗牌魔力都需要发生在ShuffleRange
类中,因为它将自己分成了子范围。ShuffleRange
的等级定义如图 16-24 所示。
ShuffleRange
对范围概念建模,定义复制构造器、拆分构造器、empty
方法、is_divisible
方法和设置为false
的is_splittable_in_proportion
成员变量。这个类还包含描述数组元素的begin
和end
迭代器以及一个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_item
或try_process_item
函数来显式处理这些过滤器中的项目。
通常情况下,thread_bound_filter
并不用于改善数据局部性,而是在过滤器必须在特定线程上执行时使用——可能是因为只有该线程有权访问完成过滤器执行的操作所需的资源。这种情况在实际应用中可能会出现,例如,当一个通信或卸载库要求所有通信都来自一个特定线程时。
让我们考虑一个模拟这种情况的人为例子,其中只有主线程可以访问一个打开的文件。要使用thread_bound_filter,
,我们需要使用tbb::pipeline
的类型不安全类接口。使用tbb::parallel_pipeline
功能时,我们无法创建thread_bound_filter
。我们很快就会明白为什么使用带有parallel_pipeline
接口的thread_bound_filter
是没有意义的。
在我们的例子中,我们创建了三个过滤器。我们的大多数过滤器将继承自tbb::filter
,覆盖operator()
函数:
我们的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
,而是必须在主线程上进行写操作。
图 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 中的示例,我们看到我们已经解决了问题:
图 16-26
仅从主线程写入output
的示例
Done.
摘要
在这一章中,我们深入研究了可以用来调整 TBB 算法的特性。我们围绕调优 TBB 应用程序时的三个常见问题展开讨论:任务粒度、可用并行性和数据局部性。
对于循环算法,我们主要关注阻塞范围类型和不同的划分器类型。我们发现,我们可以使用 1 微秒作为任务应该执行多长时间的一般指导,以减轻任务调度的开销。这一粗略的准则适用于两种循环算法,如parallel_for
,也适用于parallel_pipeline
中的滤波器尺寸。
我们讨论了如何使用阻塞范围类型来控制粒度以及优化内存层次结构。我们使用了blocked_range2d
和simple_partitioner
来实现矩阵转置的缓存无关实现。然后,我们展示了如何使用affinity_partitioner
或static_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.
如需了解更多关于雷群问题的信息:
开放存取本章根据知识共享署名-非商业-非专用 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 所示。
图 17-1
串行:对基线串行循环计时的功能
FG 循环功能如图 17-2 所示。这个函数构建了一个流图,它有一个从输出到输入的边。单个消息开始循环,然后节点旋转等待并向其输入发送回一个消息。该循环重复 N-1 次。因为节点在将消息发送回输入端之前会旋转,所以这个图仍然是一个串行循环——主体任务中的大部分工作不会重叠。但是,因为消息是在主体返回之前发送的,所以仍然有一小段时间间隔,在此期间另一个线程可以窃取try_put
生成的任务。我们可以使用这个图来查看流图基础设施的基本开销。
图 17-2
FG 循环:一个为串行流图计时的函数
我们的下一个微基准函数,主循环,如图 17-3 所示,不创建循环。相反,它在串行循环中直接从主线程向multifunction_node
发送所有 N 条消息。由于multifunction_node
具有无限的并行性,并且串行 for-loop 会非常快地发送消息,因此创建了许多并行任务。然而,因为主线程是唯一调用节点n
上的try_put
方法的线程,所以所有主体任务都被生成到主线程的本地队列中。参与执行该图的工作线程将被迫窃取它们执行的每个任务——并且只有在它们随机选择主线程作为受害者之后。我们可以使用这个图来查看具有足够并行性的流图的行为,但是这需要大量的工作量。
图 17-3
主循环:仅从主线程提交消息的函数;工人必须窃取他们执行的每一项任务
最后,图 17-4 显示了每个工人功能的 FG 循环。这个函数将任务分布在主线程和工作线程的本地队列中,因为一旦一个线程窃取了它的初始任务,它就会将任务生成到它自己的本地队列中。我们可以用这个图来看一个流量图的行为,有非常少量的窃取。
图 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 微秒的任务规模时,我们能够达到接近完美的线性加速。
图 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 循环的良好行为,其中每个线程都能够从其本地队列中快速弹出下一个任务。现在我们在时间线上看到很少的间隙。
图 17-6
当使用 1 微秒的自旋等待时,每个微基准测试的两毫秒时间线区域
观察这些极端的行为并注意图 17-5 中的性能,我们很乐意为流图节点推荐一个类似的经验法则。虽然一个病理案例,如主循环,在 1 微秒的时间内显示了 2.8 的有限加速,但它仍然显示了加速。如果工作更加平衡,比如每个工人使用 FG 循环,1 微秒的身体提供了很好的加速。考虑到这些警告,我们再次推荐 1 微秒的执行时间作为一个粗略的准则:
经验法则
为了从并行执行中获益,流图节点的执行时间应该至少为 1 微秒。这相当于几千个 CPU 周期——如果你喜欢使用周期,我们建议一个10,000 cycle
经验法则。
就像 TBB 算法一样,这个规则并不意味着我们必须不惜一切代价避免小于 1 微秒的节点。只有当我们的流图的执行时间由小节点支配时,我们才真正有问题。如果我们混合了具有不同执行时间的节点,那么与较大节点的执行时间相比,小节点引入的开销可以忽略不计。
如果节点太小该怎么办
如果流图中的一些节点小于推荐的 1 微秒阈值,则有三种选择:(1)如果该节点对应用程序的总执行时间没有显著影响,则什么都不做,(2)将该节点与周围的其他节点合并以增加粒度,或者(3)使用lightweight
执行策略。
如果节点的粒度很小,但是它对总执行时间的贡献也很小,那么可以安全地忽略该节点;就让它保持原样。在这些情况下,清晰的设计可能会胜过任何无关紧要的效率。
如果必须解决节点的粒度问题,一种选择是将其与周围的节点合并。节点真的需要和它的前任和继任者分开封装吗?如果节点只有一个前任或一个继任者,并且具有相同的并发级别,那么它可能很容易与这些节点合并。如果它有多个前趋者或后继者,那么由该节点执行的操作可能会被复制到每个节点中。在任何情况下,如果合并不改变图的语义,将节点合并在一起是一种选择。
最后,在构造节点时,可以通过模板参数将节点更改为使用轻量级执行策略。例如:
该策略表示节点主体包含少量工作,如果可能的话,应该在没有任务调度开销的情况下执行。
有三种轻量级策略可供选择:queueing_lightweight
、rejecting_lightweight
、lightweight
、??【这些策略在附录 b 中有详细描述,除了source_node
之外,所有功能节点都支持轻量级策略。轻量级节点可能不会产生执行主体的任务,而是在调用线程的上下文中直接在try_put
内执行主体。这意味着派生的开销被移除了——但是其他线程没有机会窃取任务,因此并行性受到了限制!
图 17-7 显示了两个简单的图形,我们可以用它们来展示轻量级策略的好处和风险:第一个是一个链multifunction_node
对象,第二个是一个连接到两个链multifunction_node
对象的multifunction_node
对象。
图 17-7
用于检查lightweight
政策影响的流程图
图 17-8 显示了使用lightweight
策略对图 17-7 所示图表的影响,图中使用了 1000 个节点的链,都使用相同的执行策略(lightweight
或不使用)。我们通过每个图形发送一条消息,并改变每个节点旋转的时间,从 0 到 1 毫秒不等。我们应该注意,当只发送一条消息时,单链不允许任何并行性,而使用两条链,我们可以实现 2 倍的最大加速。
图 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_node
、function_node
和continue_node
,如果其中一个后继节点可以立即运行,则使用调度程序旁路。如果这些节点中的一个访问的数据适合数据缓存,那么它可以在执行后继节点时被同一个线程重用。
由于我们可以从流图中的局部性中受益,因此值得考虑数据大小,甚至将数据分成更小的部分,这样可以通过调度程序旁路从局部性中受益。例如,我们可以重温一下我们在第十六章中使用的矩阵转置内核,作为演示这种效果的例子。我们现在将使用图 17-9 所示的FGMsg
结构传递三对a
、b
矩阵。你可以在图 16-6 到图 16-13 中看到第十六章矩阵转置内核的串行、高速缓存不经意和并行实现。
图 17-9 中也显示了我们第一个没有将数组分成小块的实现。source_node, initialize
发送三条消息,每条消息是三个矩阵对之一。这个节点连接到一个具有无限并发性的function_node, transpose
。transpose
节点调用第十六章中的简单串行矩阵转置函数。最后一个节点check
确认转置正确完成。
图 17-9
发送一系列矩阵进行转置的图形,每个矩阵都使用第十六章中的简单串行矩阵转置进行转置
我们的简单实现发送完整的矩阵,这些矩阵由transpose
以非缓存无关的方式进行处理。正如我们可能预料的那样,这并没有很好地执行。在我们的测试机器上,它只比连续三次执行第十六章中矩阵转置的非缓存无关串行实现快 8%,每对矩阵执行一次。这并不奇怪,因为基准测试是受内存限制的——当我们无法从内存中获取一个转置所需的数据时,尝试并行执行多个转置并没有多大帮助。如果我们将我们的简单流程图与第十六章中的串行缓存无关转置进行比较,它看起来甚至更糟,当在我们的测试机上执行时,需要 2.5 倍长的时间来处理三对矩阵。幸运的是,有许多方法可以提高这个流程图的性能。例如,我们可以在transpose
节点中使用串行缓存无关实现。或者,我们可以使用第十六章中的parallel_for
实现,它在transpose
节点中使用了blocked_range2d
和simple_partitioner
。我们将很快看到,这些都将极大地提高我们的基础案例加速 1.08。
然而,我们也可以将矩阵块作为消息发送,而不是将每对a
和b
矩阵作为一个大消息发送。为此,我们扩展了我们的消息结构,以包含一个blocked_range2d
:
然后我们可以构建一个实现,其中initialize
节点将a
和b
矩阵的块作为消息发送;在移动到下一个矩阵之前发送来自一对矩阵的所有块。图 17-10 显示了一种可能的实现方式。在这种实现中,堆栈由source_node
维护,以模拟深度优先细分和块的执行,这将通过由 TBB parallel_for
执行的范围的递归细分来实现。我们将不深入描述图 17-10 中的实现。相反,我们将简单地注意到它发送的是块而不是全矩阵。
图 17-10
利用第十六章【高级算法】中描述的 blocked_range2d,发送一系列矩阵块进行转置的图形
图 17-11 显示了在我们的测试机上执行矩阵转置的几个变体的加速。我们可以看到,我们的第一个实现,标记为“流图”,显示了 8%的小改进。pfor-br2d 实现是图 16-11 中基于parallel_for
的实现,其中blocked_range2d
和simple_partitioner,
执行三次,每对矩阵执行一次。其余的条都对应于优化的流图版本:“流图+不经意”类似于图 17-9 ,但是从transpose
节点体内部调用矩阵转置的串行缓存不经意实现;“流图+ pfor-br2d”在transpose
体中使用了一个parallel_for
;“平铺流图”是我们在图 17-10 中的实现;“平铺流图+ pfor2d”与图 17-10 相似,但使用了一个parallel_for
来处理其平铺。图 17-10 中的平铺流程图表现最佳。
图 17-11
矩阵转置的不同变体的加速。我们使用 32×32 的瓦片,因为这在我们的测试系统上表现最好。
令人惊讶的是,具有嵌套parallel_fors
的平铺流图版本的性能不如没有嵌套并行的平铺流图。在第九章中,我们声称我们可以在 TBB 不受惩罚地使用嵌套并行——那么哪里出错了呢?残酷的现实是,一旦我们开始调优我们的 TBB 应用程序的性能——我们经常需要牺牲完全的可组合性来换取性能(参见可组合性方面侧栏)。在这种情况下,嵌套并行性干扰了我们小心翼翼地尝试实现的缓存优化。每个节点都被发送了一个适合其数据缓存的切片进行处理——通过嵌套并行,我们通过与其他线程共享切片来消除这种完美的匹配。
可组合性方面
我们可以将可组合性分解为三个愿望:
-
正确性(作为绝对)
-
使用能力(作为实际问题)
-
绩效(作为一种期望)
首先,我们希望可以混合和匹配代码,而不用担心它会突然出现故障(得到错误的答案)。TBB 给了我们这种能力,这在很大程度上是一个已经解决的问题——一个问题是,当使用有限精度数学(如本机浮点运算)时,不确定的执行顺序会使答案不同。我们在第十六章中讨论了这一点,提供了维护可组合性的“正确性”方面的方法。
第二,我们希望程序不会崩溃。在许多情况下,这是一个实际问题,因为最常见的问题(无限制的内存使用)理论上可以用无限大小的内存来解决。☺ TBB 在很大程度上解决了这方面的可组合性,使其具有编程模型所不具备的优势(如 OpenMP)。TBB 在结构化程度较低的流程图方面确实需要更多的帮助,所以我们讨论在流程图中使用
limiter_nodes
来控制内存使用——这在大型流程图中尤其重要。最后,对于最佳性能,我们不知道全面性能可组合性的通用解决方案。现实情况是,高度优化的代码与运行在同一硬件上的其他代码竞争,会干扰任一代码的最佳性能。这意味着我们可以从手动调整代码中获益。幸运的是,TBB 为我们提供了调优控制,像 Flow Graph Analyzer 这样的工具有助于我们洞察并指导我们的调优。一旦调优,我们的经验是代码可以很好地工作,感觉是可组合的——但是盲目使用代码并获得最高性能的技术并不存在。“足够好”的表现可能经常发生,但“伟大”需要努力。
我们不应该过于关注图 17-11 中结果的细节——毕竟,这是一个内存受限的微基准测试。但是它清楚地表明,我们可以从考虑节点的大小中获益,不仅从粒度角度,而且从数据局部性角度。当我们从一个发送整个数组并且没有在节点中实现经过调整的内核的简单实现转移到我们的更能感知缓存的平铺流图版本时,我们看到了显著的性能提升。
挑选最佳消息类型并限制传输中的消息数量
当我们允许消息进入一个图,或者当我们通过一个流图沿着多条路径分割它们时,我们消耗更多的内存。除了担心局部性,我们可能还需要限制内存增长。
当消息被传递到数据流图中的节点时,它可能被复制到该节点的内部缓冲区中。例如,如果一个串行节点需要推迟任务的生成,它会将传入的消息保存在一个队列中,直到可以合法地生成一个任务来处理它们。如果我们在流图中传递非常大的对象,这种复制会非常昂贵!因此,如果可能的话,最好是传递指向大型对象的指针,而不是对象本身。
C++11 标准引入了类(在namespace std) unique_ptr
和shared_ptr
中),这对于简化流图中指针传递的对象的内存管理非常有用。例如,在图 17-12 中,让我们假设一个BigObject
很大并且建造很慢。通过使用shared_ptr
传递对象,只有shared_ptr
被复制到串行节点n
的输入缓冲区,而不是整个BigObject
。此外,由于使用了shared_ptr
,一旦每个BigObject
到达图的末尾并且其引用计数达到 0,它就会被自动销毁。多方便啊!
图 17-12
使用std::shared_ptr
避免缓慢复制,同时简化内存管理
当然,当我们使用指向对象的指针时,我们需要小心。通过传递指针而不是对象,多个节点可以通过shared_ptr
同时访问同一个对象。如果您的图依赖于功能并行性,即相同的消息被广播到多个节点,这一点尤其正确。shared_ptr
将正确处理引用计数的递增和递减,但是我们需要确保在访问所指向的对象时,我们正确地使用了边来防止任何潜在的竞争情况。
正如我们在讨论节点如何映射到任务时所看到的,当消息到达功能节点时,可能会产生任务或者缓冲消息。在设计数据流图时,我们不应该忘记这些缓冲区和任务,以及它们的内存占用。
例如,让我们考虑图 17-13 。有两个节点,serial_node
和unlimited_node
;两者都包含一个长自旋循环。for
循环为两个节点快速分配大量输入。节点serial_node
是串行的,因此它的内部缓冲区将快速增长,因为它接收消息的速度比它完成任务的速度快。相比之下,节点unlimited_node
将在每条消息到达时立即产生任务——用大量任务迅速淹没系统——远远超过工作线程的数量。这些产生的任务将在内部工作线程队列中缓冲。在这两种情况下,我们的图可能会很快消耗大量内存,因为它们允许 BigObject 消息进入图中的速度比它们被处理的速度更快。
我们的例子使用了一个原子计数器bigObjectCount
,来跟踪在任何给定时间当前分配了多少个ObjectCount
对象。在执行结束时,该示例打印最大值。当我们用A_VERY_LARGE_NUMBER=4096
运行图 17-13 中的代码时,我们看到了一个"maxCount == 8094"
。serial_node
和unlimited_node
都可以快速积累 BigObject 对象!
图 17-13
一个例子有连续的function_node
、serial_node
,还有无限的function_node
、unlimited_node
有三种常见的方法来管理流程图中的资源消耗:(1)使用limiter_node
, (2)使用并发限制,和/或(3)使用令牌传递模式。
我们使用一个limiter_node
来设置可以通过图中给定点的消息数量的限制。limiter_node
的接口子集如图 17-14 所示。
图 17-14
示例使用的limiter_node
接口的子集
一个limiter_node
维护通过它的消息的内部计数。发送到limiter_node
上的decrement
端口的消息会减少计数,允许更多的消息通过。如果计数等于节点的threshold
,任何到达其输入端口的新消息都将被拒绝。
在图 17-15 中,一个source_node source
生成大量的BigObjects
。一旦先前生成的消息被消耗掉,一个source_node
只会产生一个新的任务来生成一条消息。我们在source
和unlimited_node
之间插入一个limiter_node
limiter
,构造为限制 3,以限制发送到unlimited_node
的消息数量。我们还添加了一个从unlimited_node
回到limiter_node
递减端口的边沿。通过limiter
发送的消息数量现在最多比通过limiter
的减量端口发回的消息数量多 3。
图 17-15
使用一个limiter_node
一次只允许三个BigObjects
到达unlimited_node
我们还可以使用节点上的并发限制来限制资源消耗,如图 17-16 所示。在代码中,我们有一个可以无限并发地安全执行的节点,但是我们选择了一个较小的数字来限制并发产生的任务数量。
图 17-16
使用一个tbb::flow::rejecting
策略和一个concurrency_limit
来一次只允许三个BigObjects
到达limited_to_3_node
我们可以通过构造一个执行策略、flow::rejecting
或flow::rejecting_lightweight
来关闭function_node
的内部缓冲。图 17-16 中的source_node
只有在被消耗时才会继续产生新的输出。
在数据流图中限制资源消耗的最后一种常用方法是使用基于令牌的系统。如第二章所述,tbb::parallel_pipeline
算法使用令牌来限制管道中将要运行的项目的最大数量。我们可以使用令牌和预留join_node
创建一个类似的系统,如图 17-17 所示。在这个例子中,我们创建了一个source_node source
和buffer_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,正如我们所预期的那样。
图 17-18
这三种方法都限制了加速,因为一次只有三个项目被允许进入节点 n
图 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,我们用八个槽来初始化它。
图 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_for
和parallel_reduce
,都经过了高度优化,包括范围和分割器等功能,让我们可以进一步优化性能。我们还看到流图接口非常有表现力——我们可以表达包含循环的图,并使用像multifunction_node
这样的节点从每次调用中输出许多消息。因此,我们应该注意在图中创建模式的情况,这些模式可以使用嵌套并行更好地表达。一个简单的例子如图 17-20 所示。
图 17-20
一个multifunction_node
为它接收的每条消息发送许多消息。这种模式最好用嵌套的parallel_for
循环来表达。
在图 17-20 中,对于multifunction_node
接收到的每条消息,它都会生成许多输出消息,这些消息会无限并发地流入function_node
中。这个图很像一个并行循环,其中multifunction_node
作为控制循环,function_node
作为主体。但是像图 17-3 和 17–5 中的主循环一样分发工作需要大量的窃取。虽然这种模式可能有一些有效的用法,但是使用高度优化的并行循环算法可能更有效。例如,整个图可以折叠成一个包含嵌套的parallel_for
的节点。当然,这种替换是否可能或需要取决于应用。
Do:需要时,使用join_node
、sequencer_node
或multifunction_node
在流程图中重新建立顺序
因为流图不如简单的管道结构化,所以我们有时可能需要在图中的点上建立消息的顺序。在数据流图中建立顺序有三种常见的方法:使用键匹配join_node
,使用sequencer_node
,或者使用multifunction_node
。
例如,在第三章中,我们的立体 3D 流程图中的并行性允许左右图像在mergeImageBuffersNode
点无序到达。在那个例子中,我们通过使用标签匹配join_node
来确保正确的两幅图像被配对在一起作为mergeImageBuffersNode
的输入。标签匹配join_node
是一种密钥匹配join_node.
类型,通过使用这种join_node
类型,输入可以以不同的顺序到达两个输入端口,但仍然会根据它们的标签或密钥进行正确匹配。您可以在附录 b 中找到关于不同连接策略的更多信息。
另一种建立顺序的方法是使用sequencer_node
。sequencer_node
是一个缓冲区,它按照序列顺序输出消息,使用用户提供的 body 对象从传入的消息中获取序列号。
在图 17-21 中,我们可以看到一个三节点图,节点有first_node
、sequencer
和last_node
。在最后一个串行输出节点last_node
之前,我们使用一个sequencer_node
来重新建立消息的输入顺序。因为function_node first_node
是无限的,它的任务可以无序完成,并在完成时发送它们的输出。sequencer_node
通过使用最初构造每个消息时分配的序列号来重新建立输入顺序。
如果我们执行一个没有序列器节点且N
=10 的类似示例,当消息在去往last_node
的途中相互传递时,输出被打乱:
图 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
的每个元素,序列号递增。对于某些传入消息,将不发送输出消息;对于其他人,可能会发送一条或多条消息。
图 17-22
一个multifunction_node
用于实现一个sequencer_node
虽然图 17-22 显示了如何使用multifunction_node
按序列顺序对消息进行重新排序,但一般来说,可以使用任何用户定义的消息排序或捆绑。
Do:使用Isolate
函数进行嵌套并行
在第十二章中,我们谈到了在使用 TBB 算法时,我们有时会因为性能或正确性的原因而需要创建隔离。对于流图来说也是如此,对于通用算法来说,对于嵌套并行来说尤其如此。图 17-23 中的图的实现显示了一个简单的图,图中有节点source
和unlimited_node
,节点unlimited_node
中有嵌套并行。在等待节点unlimited_node
中嵌套的parallel_for
循环完成时,线程可能会兼职(参见第十二章),并拾取节点unlimited_node
的另一个实例。节点unlimited_node
打印“X started by Y
”,其中X
是节点实例号,Y
是线程 id。
图 17-23
具有嵌套并行的图
在我们有八个逻辑内核的测试系统上,一个输出显示我们的线程 0 非常无聊,它在等待第一个parallel_for
算法完成时,拾取了不止一个,而是三个不同的unlimited_node
实例,如图 17-24 所示。
图 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 所示。
图 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
抛出一个异常。
图 17-26
在其一个节点中抛出异常的流图
如果我们执行这个例子,我们会得到一个异常(希望这不是一个意外):
terminate called after throwing an instance of 'int'
由于我们没有处理异常,它传播到外部范围,我们的程序终止。当然,我们可以修改我们的节点node2
的实现,这样它就可以在自己的主体中捕获异常,如图 17-27 所示。
图 17-27。
在其一个节点中抛出异常的流图
如果我们做了这样的更改,我们的示例将运行完成,打印出“捕获”的消息,没有特定的顺序:
Caught 2
Caught 1
到目前为止,这些都不是非常特殊的(双关语);这就是异常应该如何工作。
流程图中异常处理的独特之处在于,我们可以在调用图的wait_for_all
函数时捕捉异常,如图 17-28 所示。
图 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
。
图 17-29
一个 body 类,让我们推断出节点执行使用了什么任务 arena 和task_group_context
图 17-30 提供了一个使用WhereAmIRunningBody
演示意外行为的例子。在这个例子中,我们创建了两个节点:g2_node
和g4_node
。节点g2_node
是参照g2
构建的。图g2
被传递对具有priority_normal
的task_group_context
的引用,并且g2
是并发度为 2 的task_arena
中的reset()
。因此,我们应该期望g2_node
在一个有两个线程的竞技场中以正常的优先级执行,对吗?节点g4_node
是这样构造的,我们应该期望它在一个有四个线程的竞技场中以高优先级执行。
包含g2_node.try_put(0)
和g4_node.try_put(1)
的第一组呼叫符合这些预期:
图 17-30
由于跨图通信而出现意外行为的示例
但是,当我们从g2_node
到g4_node
创建一条边时,我们在两个不同图中存在的节点之间创建了一个连接。我们包含g2_node.try_put(2)
的第二组调用再次导致g2_node
的主体在arena a2
中以正常优先级执行。但是 TBB 试图减少调度开销,由于从g2_node
到g4_node
的边沿,当它调用g4_node
时,使用调度程序旁路(参见第十章中的调度程序旁路)。结果就是g4_node
和g2_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 a2
和a4
来控制线程的数量,用于工作隔离或线程关联的目的,这个边缘将撤销我们的努力。我们 不应该 在图形之间制造边。
Do:使用try_put
跨图形进行交流
在前面的“不要”中,我们决定不要在图形之间创建边。但是如果我们真的需要跨图交流呢?最不危险的选择是显式调用try_put
将消息从一个图中的节点发送到另一个图中的节点。我们没有引入边缘,所以 TBB 库不会偷偷摸摸地优化两个节点之间的通信。即使在这种情况下,我们仍然需要小心,如图 17-31 中的例子所示。
这里,我们创建一个图g2
,它向图g1
发送一条消息,然后等待图g1
和图g2
。但是,等待的顺序是错误的!
由于节点g2_node2
向g1_node1
发送消息,对g1.wait_for_all()
的调用可能会立即返回,因为在调用时g1
中没有发生任何事情。然后我们调用g2.wait_for_all()
,它在g2_node2
完成后返回。该调用返回后,g2
结束,但g1
刚刚收到来自g2_node2
的消息,其节点g1_node1
刚刚开始执行!
图 17-31
向另一个流程图发送消息的流程图
幸运的是,如果我们以相反的顺序调用等待,事情会像预期的那样进行:
g2.wait_for_all();
g1.wait_for_all();
但是,我们仍然可以看到使用显式try_puts
并非没有危险。当图形相互通信时,我们需要非常小心!
Do:使用composite_node
封装节点组
在前两节中,我们警告过图之间的通信会导致错误。开发人员经常使用不止一个图,因为他们想在逻辑上将一些节点与其他节点分开。如果有一个需要多次创建的公共模式,或者如果在一个大的平面图中有太多的细节,那么封装一组节点是很方便的。
在这两种情况下,我们都可以使用一个tbb::flow::composite_node
。一个composite_node
用于封装其他节点的集合,这样它们就可以像一个一级图节点一样使用。其界面如下:
与我们在本章和第三章中讨论的其他节点类型不同,我们需要创建一个从tbb::flow::composite_node
继承的新类来使用它的功能。例如,让我们考虑图 17-32(a) 中的流程图。该图结合了来自source1
和source2
的两个输入,并使用令牌传递方案来限制内存消耗。
图 17-32
一个受益于composite_node
的例子
如果这种令牌传递模式在我们的应用程序中经常使用,或者被我们开发团队的成员使用,那么将它封装到它自己的节点类型中可能是有意义的,如图 17-32(b) 所示。它还通过隐藏细节来清理应用程序的高级视图。
图 17-33 显示了如果我们有一个节点实现了图 17-32(a) 的虚线部分,用一个单独的merge
节点代替它,流程图实现看起来会是什么样子。在图 17-33 中,我们像任何其他流图节点一样使用merge
节点对象,为其输入和输出端口创建边。图 17-34 显示了我们如何使用tbb::flow::composite_node
来实现我们的MergeNode
类。
图 17-34
MergeNode
的实施
图 17-33
创建使用从tbb::flow::composite_node
继承的类MergeNode
的流图
在图 17-34 中,MergeNode
继承了CompositeType
,?? 是的别名
两个模板参数表明一个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 图形用户界面。在这里,我们将仅简要描述该工具的组件,因为我们描述了典型的工作流;流程图分析器文档提供了更完整的描述。
图 17-35
使用 FGA 设计工作流程
典型的设计工作流程从空白画布和项目开始。如图 17-35 中编号为 1 的黑色圆圈所示,我们在节点调色板中选择节点,并将它们放置在画布上,通过绘制端口之间的边将它们连接在一起。节点面板包含 TBB 流图界面中所有可用的节点类型,并提供工具提示,提醒我们每种类型的功能。对于画布上的每个节点,我们可以修改其特定于类型的属性;例如,对于一个function_node
,我们可以为主体提供 C++ 代码,设置并发限制,等等。我们还可以提供一个估计的“权重”,表示节点的计算复杂度,以便稍后我们可以运行一个可伸缩性分析,看看我们的图是否会表现良好。
一旦我们在画布上绘制了图形,我们就运行一个规则检查来分析图形,寻找常见的错误和反模式。在图 17-35 中用黑色圆圈 2 突出显示的规则检查结果显示了诸如不必要的缓冲、类型不匹配、图中可疑循环等问题。在图 17-35 中,规则检查发现在我们的limiter_node
的输入和我们的multifunction_node
的输出之间存在类型不匹配。作为响应,我们可以修改multifunction_node
的端口输出类型来解决这个问题。
当我们修复了规则检查发现的所有正确性问题后,我们就可以运行可伸缩性分析了。可伸缩性分析在内存中构建了一个 TBB 流图,用虚拟体替换了计算节点体,虚拟体在与它们的“重量”属性成比例的时间内活跃地旋转。FGA 在不同数量的线程上运行我们的图表模型,并提供了一个加速表,例如:
使用这些特性,我们可以迭代地改进我们的图形设计。在这个过程中,我们可以将图形设计保存为 GraphML 格式(一种表示图形的通用标准)。当我们对我们的设计满意时,我们可以生成 C++ 代码,该代码使用 TBB 流图接口来表达我们的设计。这个代码生成器更准确地说是一个代码向导,而不是 IDE,因为它不直接支持迭代代码开发模型。如果我们更改了生成的代码,就没有办法将我们的更改重新导入到工具中。
FGA 迭代开发技巧
如果我们想创建一个可以在 FGA 内部继续调优的设计,我们可以使用一种受约束的方法,在这种方法中,我们指定重定向到在 FGA 之外维护的实现的节点体。这是必要的,因为没有办法将修改后的 C++ 代码重新导入 FGA。
例如,如果我们想使迭代开发更容易,我们不应该指定一个直接在主体代码中公开其实现的function_node
:
相反,我们应该只指定接口,并重定向到可以单独维护的实现:
如果我们采用这种受约束的方法,我们通常可以维护 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)检查时间轴和并发数据以了解性能随时间的变化。分析通常是一个交互过程,随着应用程序性能的探索,它在这些不同的活动之间移动。
图 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 毫秒——这超过了有效调度的阈值,但从时间表来看,这似乎是一个序列化瓶颈。如果可能的话,我们应该将这个任务分解成可以并行调度的任务——或者通过分解成多个独立的节点,或者通过嵌套并行。
图 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) 中,我们通过指针传递,消息传递时间可以忽略不计,因此看不到间隙。
图 17-38
在 FGA 中,长副本显示为节点体执行之间的间隙。所示的每个时间线段大约 100 毫秒长。
当使用 FGA 分析我们的流程图应用程序时,时间线上的缺口表明效率低下,需要进一步调查。在本节中,他们指出了节点之间的高成本复制,在上一节中,他们指出了与任务大小相比,调度的开销很大。在这两种情况下,这些差距应该促使我们寻找提高性能的方法。
使用 FGA 诊断兼职
在本章的前面,我们讨论了图 17-23 中的兼职图的执行,它产生了图 17-24 中的输出。FGA 在其执行时间表中提供了一个堆叠视图,让我们可以轻松发现兼职,如图 17-39 所示。
图 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/
。
开放存取本章根据知识共享署名-非商业-非专用 4.0 国际许可协议(http://Creative Commons . org/licenses/by-NC-nd/4.0/)的条款进行许可,该协议允许以任何媒体或格式进行任何非商业使用、共享、分发和复制,只要您适当注明原作者和来源,提供知识共享许可协议的链接,并指出您是否修改了许可材料。根据本许可证,您无权共享从本章或其部分内容派生的改编材料。
本章中的图像或其他第三方材料包含在该章的知识共享许可中,除非该材料的信用额度中另有说明。如果材料未包含在本章的知识共享许可中,并且您的预期用途不被法定法规允许或超出了允许的用途,您将需要直接从版权所有者处获得许可。
十八、使用异步节点增强流程图
早在 2005 年,Herb Sutter 写了“免费的午餐结束了” 1 的论文来警告我们多核时代的到来及其对软件开发的影响。在多核时代,关心性能的开发人员再也不能坐以待毙,懒洋洋地等待下一代处理器,以便高兴地看到他们的应用运行得更快。那些日子早已过去。Herb 的意思是,希望充分利用现代处理器的开发人员必须接受并行技术。在这本书的这一点上,我们当然知道这一点,那又怎么样呢?嗯,我们认为今天“午餐越来越贵了。”我们来详细阐述一下这个。
近年来,在能源限制的强烈推动下,更复杂的处理器已经出现。如今,不难发现包含一个或多个 GPU、FPGA 或 DSP 以及一个或多个多核 CPU 的异构系统。就像我们采用并行来充分利用所有 CPU 内核一样,现在将部分计算卸载到这些加速器上也是有意义的。但是,嘿,这太难了!是的,它是!如果顺序编程曾经是“免费的午餐”,那么今天的异构并行编程更像是三星级米其林餐厅的盛宴——我们必须付费,但它太棒了!
TBB 有助于节省一些晚餐的价格吗?当然可以!你怎么敢怀疑?在本书的这一章和下一章中,我们将介绍 TBB 图书馆最近整合的功能,以帮助人们再次负担得起午餐——我们将展示如何将计算卸载到异步设备,从而拥抱异构计算。在这一章中,我们将采用 TBB 流图接口,并用一种新的节点类型来加强它:async_node
。在下一章中,我们将更进一步,将流图放在 OpenCL Steroids 上。
异步世界示例
让我们从使用async_node
的最简单的例子开始。我们将说明为什么这个特定的流图节点是有用的,我们还将给出一个对下一章有用的更复杂的例子。
因为没有比“Hello World”代码片段更简单的了,所以我们提出了一个基于流图 API 的“Async World
”替代方案,它在图中包含了一个async_node
。如果您对 TBB 的流程图有疑问,您可能希望通读第三章以获得可靠的背景信息,并使用附录 B 中的“流程图”部分作为 API 的参考。我们在第一个例子中建立的流程图如图 18-1 所示。
图 18-1
“异步世界”示例的流程图
我们的目标是从一个source_node
、in_node
向一个asynchronous node
、a_node
发送一条消息,但是这个任务不是在a_node
内部处理消息,而是被卸载到一个正在某个地方运行的异步活动(一个不同的 MPI 节点,一个 OpenCL 支持的 GPU,一个 FPGA,你能想到的)。一旦这个异步任务完成,流图引擎必须取回控制,读取异步活动的输出,并将消息传播到图中的后代节点。在我们非常简单的"Async World
"例子中,in_node
只是打印出"Async
,并将a=10
传递给a_node
。a_node
接收a=10
作为input
并将其转发给AsyncActivity
。在这个例子中,AsyncActivity
是一个类,它只是增加输入消息并输出“World!
”。这两个动作在一个新的线程中执行,该线程在这里模拟一个异步操作或设备。只有当AsyncActivity
设计用output=11
响应时,out_node
才会收到该值,程序结束。
图 18-2 中的代码包含了async_world()
函数定义,我们在其中构建了由图 18-1 的三个节点组成的图g
。
图 18-2
构建“Async World
”示例的流程图
在附录 B 的图 B-37 的表格的第一个条目中描述了source_node
接口。在我们的例子中,我们创建了source_node
类型的in_node
。lambda 的参数int& a
实际上是将被发送到图中它的后继节点async_node
的输出消息。当源节点在接近async_world()
函数结束时被激活,通过使用in_node.activate()
,lambda 将只被执行一次,因为它只为第一次调用返回true
(最初n=false
,n
在 lambda 中被设置为true
,只有在n=true
时才返回 true)。在这个调用中,带有a=10
的消息被发送到图中的下一个节点。in_node
的最后一个参数是false
,以便源节点在休眠模式下创建,并且仅在调用in_node.activate()
后唤醒(否则,节点在输出边沿连接后立即开始发送消息)。
接下来是async_node
定义。async_node
接口所需的语法是
在我们的例子中,a_node
是在这里构造的:
这在图g
中创建了一个具有unlimited
并发性的async_node<int, int>
。通过使用unlimited
,我们指示库在消息到达时立即生成一个任务,而不管已经生成了多少其他任务。如果我们只想同时调用a_node
的4
,我们可以将unlimited
改为4
。模板参数<int, int>
指出类型int
的消息进入a_node
,类型int
的消息离开a_node
。a_node
构造器中使用的 lambda 如下:
它通过引用捕获一个AsyncActivity
对象asyncAct
,并声明对于到达a_node
的每个消息必须运行的仿函数。这个仿函数有两个参数,input
和gateway
,通过引用传递。但是等等,我们不是说过模板参数<int, int>
意味着节点期望一个传入的整数并发出一个传出的整数吗?仿子的原型不应该是(const int& input) -> int
吗?嗯,对于普通的function_node
来说应该是这样,但是我们现在面对的是async_node
和它的特殊性。这里,我们得到了预期的const int& input
,但是还有第二个输入参数gateway_t& gateway
,它作为一个接口将AsyncActivity
的输出注入到图中。我们在讲解AsyncActivity
类的时候会讲到这一招。现在,为了完成对这个节点的描述,让我们假设它基本上用asyncAct.run(input, gateway)
调度AsyncActivity
。
输出节点out_node
是一个function_node
,它在本例中被配置为不发送任何输出消息的端节点:
该节点接收来自AsyncActivity
到gateway
的整数,并完成打印“Bye!
,后跟该整数的值。
在图 18-2 中Async World
示例的最后几行,我们发现两个make_edge
调用创建了图 18-1 中描述的连接,最后该图被in_node.activate()
唤醒,立即等待,直到所有消息都被g.wait_for_all()
处理完毕。
接下来是AsyncActivity
类,它实现了我们例子中的异步计算,如图 18-3 所示。
图 18-3
异步活动的实现
公共成员函数“run
”(在a_node
的带有asyncAct.run
的仿函数中调用)首先执行gateway.reserve_wait()
,通知流程图工作已经提交给外部活动,因此在async_world()
结束时g.wait_for_all()
可以考虑到这一点。然后,产生一个异步线程来执行 lambda,它通过引用捕获gateway
,通过值捕获input
整数。通过值传递input
很关键,因为否则引用的变量source_node
中的a
可能会在线程读取其值之前被破坏(如果source_node
在asyncThread
可以读取a
的值之前结束)。
线程构造器中的 lambda 首先打印“World
”消息,然后分配output=11
( input+1
,更准确地说)。这个输出通过调用成员函数gateway.try_put(output)
传递回流程图。最后,通过gateway.release_wait()
,我们通知流程图,就AsyncActivity
而言,无需再等待。
注意
不需要为提交给外部活动的每个输入消息调用成员函数reserve_wait()
。唯一的要求是每个对reserve_wait()
的调用必须有一个对release_wait()
的相应调用。请注意,当有一些reserve_wait()
调用不匹配release_wait()
时,wait_for_all()
不会退出
结果代码的输出是
Async World! Input: 10
Bye! Received: 11
其中“Async
由in_node
写,“World! Input: 10
由异步任务写,最后一行由out_node
写。
为什么以及何时async_node
?
现在,可能会有读者表现出自负的傻笑,并认为“我不需要一个async_node
来实现它。”为什么我们不依靠好的 ol' function_node
?
例如,a_node
可以如图 18-4 所示实现,这里我们使用一个function_node
接收一个整数input
,并返回另一个整数output
。相应的 lambda 表达式生成一个线程asyncThread
,它打印并生成output
值,然后等待线程完成asyncThread.join()
并愉快地返回output
。
图 18-4
创建并等待异步线程的最简单的实现。有人说危险吗?
如果你以前不是那种傻笑的读者,那现在呢?因为,这个简单得多的实现有什么问题?为什么不依靠同样的方法将计算卸载到 GPU 或 FPGA,然后等待加速器完成它的任务呢?
要回答这些问题,我们必须回到 TBB 设计的一个基本标准,即可组合性要求。TBB 是一个可组合的库,因为如果开发人员决定或需要在其他并行模式中嵌套并行模式,无论嵌套了多少层,性能都不会受到影响。使 TBB 成为可组合的因素之一是,添加嵌套的并行级别不会增加工作线程的数量。这反过来又避免了超额认购及其相关的开销破坏我们的性能。为了充分利用硬件,TBB 通常被配置为运行与逻辑核心一样多的工作线程。各种 TBB 算法(嵌套或非嵌套)只添加足够的用户级轻量级任务来支持这些工作线程,从而利用内核。然而,正如我们在第五章中所警告的,在用户级任务中调用阻塞函数不仅会阻塞该任务,还会阻塞处理该任务的操作系统管理的工作线程。在这种不幸的情况下,如果我们每个内核都有一个工作线程,并且其中一个线程被阻塞,那么相应的内核可能会空闲。在这种情况下,我们将无法充分利用硬件!
在图 18-4 的简单例子中,asyncThread
在运行流程图控制之外的任务时将使用空闲内核。但是把工作卸载到加速器(GPU/FPGA/DSP,随你挑!),还等什么?如果一个 TBB 任务调用 OpenCL、CUDA 或 Thrust 代码(仅举几个例子)中的阻塞函数,运行这个任务的 TBB 工人将不可避免地阻塞。
在async_node
出现在节点的流程图列表中之前,一个可能的,尽管不理想的解决方法是用一个额外的线程超额订阅系统。为了实现这一点(如第十一章中更详细的描述),我们通常依赖于以下几行:
如果我们在代码中不需要流程图,只想将工作从parallel_invoke
或parallel_pipeline
的某个阶段转移到加速器,那么这个解决方案仍然是可行的。这里需要注意的是,我们应该知道,在等待加速器的大部分时间里,额外的线程都会被阻塞。然而,这种变通办法的缺点是,系统会在一段时间内超额订阅(在卸载操作之前和之后,或者甚至在加速器驱动程序决定阻止 2 线程时)。
为了避免这个问题,async_node
来拯救我们。当async_node
任务(通常是它的 lambda)完成时,负责该任务的工作线程切换到流程图的其他未决任务上。这样,工作线程不会阻塞,留下一个空闲的内核。需要记住的关键是,在async_node
任务完成之前,流程图应该被警告一个异步任务正在运行(使用gateway.reserve_wait()
),并且在异步任务将其结果重新注入流程图之后(使用try_put()
),我们应该通知异步任务已经在gateway.release_wait()
完成。还傻笑?如果有,请告诉我们原因。
更现实的例子
众所周知的流基准测试 3 的三元组函数是一个基本的数组操作,也称为“链接三元组”,它主要计算C = A +
α ∗B
,其中A
、B
和C
是 1D 数组。因此,它非常类似于实现A=A+
α ∗B
的 BLAS 1 saxpy
操作,但是将结果写入不同的向量。图示上,图 18-5 有助于理解该操作。
图 18-5
计算C = A +
α∗B
(c
i
= a
i
+
α∗b
i
,
【∀】的三元向量运算
在我们的实现中,我们将假设数组大小由变量vsize
决定,并且三个数组存储单精度浮点数。在这本书的这一点上,提出这种令人尴尬的并行算法的并行实现对我们来说还不够有挑战性。让我们来看一个异构实现。
好吧,那么你有一个集成的图形处理器?那没给我留下太多印象! 4 据报道,超过 95%的出货处理器都带有集成 GPU,与多核 CPU 共享芯片。在一个 CPU 内核上运行 triad 代码后,您会睡得很香吗?不完全是,对吗?CPU 核心不应该闲置。同理,GPU 核心也不应该闲置。在许多情况下,我们可以利用出色的 GPU 计算能力来进一步加快我们的一些应用程序。
在图 18-6 中,我们展示了三元组计算将在不同计算设备之间分配的方式。
图 18-6
三元组计算的异构实现
在我们的实现中,我们将依赖于offload_ratio
变量,它控制卸载到 GPU 的迭代空间的一部分,而其余部分在 CPU 上并行处理。0
≤ offload_ratio
≤ 1
不言而喻。
代码将基于图 18-7 所示的流程图。第一个节点in_node
是一个source_node
,它向a_node
和cpu_node
发送相同的offload_ratio
。前者是一个async_node
,它将数组的相应子区域的计算卸载到支持 OpenCL 的 GPU 上。后者是一个常规的function_node
,它嵌套了一个 TBB parallel_for
,用于在可用的 CPU 内核之间分割分配给阵列的子区域。GPU 上的执行时间Gtime
和 CPU 上的执行时间Ctime
都被收集在相应的节点中,并被转换成join_node
中的一个元组。最后,在out_node
中,打印这些时间,并且将数组 C 的异构计算版本与三元组循环的普通串行执行获得的黄金版本进行比较。
图 18-7
实现异构三元组的流程图
注意
我们喜欢温和地引入新的概念,我们试图遵循这一点,尤其是当涉及到 TBB 内容时。然而,OpenCL 超出了本书的范围,所以我们不得不放弃我们自己的规则,仅仅简单地评论一下在下面的例子中使用的 OpenCL 结构。
为了简单起见,在本例中,我们将接受以下假设:
-
为了利用零拷贝缓冲策略来减少设备间数据移动的开销,我们假设有一个 OpenCL 1.2 驱动程序可用,并且有一个 CPU 和 GPU 都可见的公共内存区域。这通常是集成 GPU 的情况。对于最近的异构芯片,OpenCL 2.0 也是可用的,在这种情况下,我们可以利用 SVM(共享虚拟内存),我们也将在接下来说明。
-
为了减少流图节点的参数数量,从而提高代码的可读性,指向三个数组
A
、B
和C
的 CPU 和 GPU 视图的指针是全局可见的。变量vsize
也是全局的。 -
为了跳过与 TBB 不太相关的方面,所有的 OpenCL 样板文件都被封装到一个函数
opencl_initialize()
中。该函数负责获取平台platform
,选择 GPU 设备device
,创建 GPU 上下文context
和命令队列queue
,读取 OpenCL 内核的源代码,编译它以创建内核,并初始化存储数组A
、B
和C
的 GPU 视图的三个缓冲区。由于AsyncActivity
也需要命令队列和程序处理程序,相应的变量queue
和program
也是全局变量。我们利用了 OpenCL C API 可用的 C++ 包装器。更准确地说,我们使用了可以在https://github.com/KhronosGroup/OpenCL-CLHPP/
上找到的cl2.hpp
OpenCL C++ 头文件。
先说代码的主要功能;在图 18-8 中,我们只展示了两个第一节点的定义:in_node
和cpu_node
。
图 18-8
具有前两个节点的异构三元组计算的主要功能
我们首先读取程序参数并初始化调用opencl_initialize()
的 OpenCL 样板文件。从这个函数中,我们只需要知道它初始化了一个 GPU 命令队列queue
,和一个 OpenCL 程序program
。线程数量的初始化以及初始化一个global_control
对象的原因将在本节的最后进行说明。GPU 内核的源代码非常简单:
这实现了三元运算,C = A +
α ∗B
,假设α =0.5
,并且浮点数组存储在全局内存中。在内核启动时,我们必须指定 GPU 将遍历的迭代范围,GPU 内部调度程序将使用指令i=get_global_id(0)
从该空间中选取单次迭代。对于这些i
中的每一个,计算C[i] = A[i] + alpha ∗ B[i]
将在 GPU 的不同计算单元中并行进行。
在opencl_initialize()
函数中,我们还分配了三个 OpenCL 缓冲区和从 CPU 端指向相同缓冲区的相应 CPU 指针(我们称之为数组的 CPU 视图)。假设我们有 OpenCL 1.2,对于输入数组 A,我们依靠 OpenCL cl::Buffer
构造器来分配一个叫做Adevice
的 GPU 可访问数组:
标志CL_MEM_ALLOC_HOST_PTR
是利用零拷贝缓冲区 OpenCL 特性的关键,因为它强制分配主机可访问的内存。同样的调用用于数组的另外两个 GPU 视图,Bdevice
和Cdevice
。为了获得指向这些缓冲区的 CPU 视图的指针,OpenCL enqueueMapBuffer
是可用的,其用法如下:
这为我们提供了一个浮点指针【the CPU 可以使用它在同一个内存区域中进行读写操作。指针Bhost
和Chost
也需要类似的调用。在具有集成 GPU 的现代处理器中,这种调用并不意味着数据复制开销,因此这种策略被称为零复制缓冲区。关于 OpenCL 还有其他一些微妙之处,比如clEnqueueUnmapMemObject()
的含义和功能,以及在同一阵列的不同区域同时写入 CPU 和 GPU 所带来的潜在问题,但这些都超出了本书的范围。
注意
如果您的设备支持 OpenCL 2.0,实现起来会更容易,尤其是如果异构芯片实现了所谓的细粒度缓冲 SVM。在这种情况下,有可能分配一个不仅对 CPU 和 GPU 可见,而且可以同时更新并由底层硬件保持一致的内存区域。为了检查 OpenCL 2.0 和细粒度缓冲 SVM 是否可用,我们需要使用:device.getInfo<CL_DEVICE_SVM_CAPABILITIES>();
为了利用这个特性,在opencl_initialize()
中,我们可以使用cl::SVMAllocator()
并将其作为std::vector
构造器的分配器模板参数传递。这将为我们提供一个std::vector A
,即同时显示数据的 GPU 视图和 CPU 视图:
这就是,再也不需要Ahost
和Adevice
了。只是A
。与任何共享数据一样,我们有责任避免数据竞争。在我们的示例中,这很容易,因为 GPU 在数组C
的一个区域中写入,该区域与 CPU 写入的区域不重叠。如果这个条件不满足,在某些情况下,解决方案是求助于原子数组。这种解决方案通常被称为平台原子或系统原子,因为它们可以由 CPU 和 GPU 自动更新。这个特性是可选实现的,它要求我们用cl::SVMTraitAtomic<>
实例化SVMAllocator
。
图 18-8 中的下一件事是图g
的声明和source_node
、in_node
的定义,它与图 18-2 中解释的非常相似,唯一的区别是它传递一个值为offload_ratio
的消息。
我们示例中的下一个节点是一个function_node
、cpu_node
,它接收一个float
(实际上是offload_ratio
)并发送一个double
(进行 CPU 计算所需的时间)。在cpu_node
lambda 中,调用了一个parallel_for
,它的第一个参数是一个阻塞范围,如下所示:
这意味着只有数组的上部会被遍历。这个parallel_for
的 lambda 为不同的迭代块并行计算Chost[i] = Ahost[i] + alpha ∗ Bhost[i]
,其中范围被自动划分。
我们可以继续图 18-9 中的下一个节点a_node
,这是一个异步节点,它接收一个浮点值(同样是offload_ratio
值)并发送 GPU 计算所需的时间。这是在a_node
的 lambda 中异步完成的,其中AsyncActivity
对象asyncAct
的成员函数run
被调用,类似于我们已经在图 18-2 中看到的。
图 18-9
具有最后三个节点定义的异构三元组计算的主要功能
join_node
不值得我们在这里浪费时间,因为它已经在第三章中讨论过了。可以说,它将一个包含 GPU 时间和 CPU 时间的元组转发到下一个节点。
最后一个节点是一个function_node
,out_node
,它接收带有时间的元组。在打印它们之前,它检查产生的C
数组是否部分在 CPU 上、部分在 GPU 上被正确计算。为此,分配C
、CGold
的黄金版本,然后使用 STL 算法transform
进行串行计算。然后,如果Chost
和CGold
重合,我们就都定好了。STL 算法可以方便地实现这种比较。
图 18-10 通过节点连接完成main()
功能,这得益于五个make_edge
调用,随后是in_node
激活以触发图的执行。我们用g.wait_for_all()
等待完成。
图 18-10
三元组主函数的最后一部分,在这里连接节点并调度图形
最后,在图 18-11 中,我们展示了AsyncActivity
类的实现,它的运行成员函数是从async_node
调用的。
图 18-11
AsyncActivity
实现,实际的 GPU 内核调用发生在这里
我们没有像在图 18-3 的AsyncActivity
中那样生成一个线程,而是遵循一个更精细、更有效的替代方案。请记住,我们推迟了对为什么在图 18-8 中使用global_control
对象的解释。在此图中,我们初始化了调度程序,如下所示:
如果您还记得第十一章中的内容,那么task_scheduler_init
行将产生以下结果:
-
将创建一个带有
nth
槽的默认竞技场(其中一个槽是为主线程保留的)。 -
工作线程将被填充到全局线程池中,一旦该领域中有工作等待处理,全局线程池将占用该领域的工作线程槽。
但是后来,global_control
对象,gc
被构造,使得全局线程池中的实际工作线程数增加。这个额外的线程在默认的竞技场中没有空位,所以它将被休眠。
现在,AsyncActivity
类,不是像我们以前那样产生一个新线程,而是唤醒休眠线程,这通常更快,特别是如果我们调用几次AsyncActivity
。为此,该类的构造器初始化了一个新的 arena,a = tbb::task_arena{1,0}
,它有一个工作线程槽,因为它为主线程保留了 0 个槽。当成员函数run()
被调用时,一个新任务与a.enqueue()
一起在这个竞技场中排队。这将导致休眠线程的分派,该线程将占据这个新竞技场的槽位并完成任务。
接下来,这个AsyncActivity
中产生的任务按照通常的步骤将计算卸载到 GPU。首先,构造triad_kernel KernelFunctor
,告知triad kernel
有三个cl::Buffer
参数。第二,调用triad_kernel
通过NDRange
,计算为ceil(vsize∗offload_ratio)
,以及缓冲区的 GPU 视图Adevice
、Bdevice
、Cdevice
。
在集成 GPU 的英特尔处理器上运行这段代码时,会生成以下两行代码:
Time cpu: 0.132203 sec.
Time gpu: 0.130705 sec.
其中vsize
设置为 1 亿个元素,我们一直在玩offload_ratio
,直到两个设备在计算分配给它们的数组子区域时消耗大约相同的时间。
摘要
在这一章中,我们首先介绍了async_node
类,它增强了流程图的功能,可以处理脱离流程图控制的异步任务。在第一个简单的Async
世界的例子中,我们展示了这个类和它的伙伴gateway
接口的使用,这对于将来自异步任务的消息重新注入流图是有用的。然后,我们激发了这个扩展与 TBB 流图的相关性,如果我们认识到阻塞 TBB 任务会导致阻塞 TBB 工作线程,这就很容易理解了。async_node
允许在流程图之外分派异步工作,但在等待异步工作完成时不会阻塞 TBB 工作线程。我们用一个更现实的例子结束了这一章,这个例子让async_node
将parallel_for
的一些迭代卸载到 GPU 上。我们希望我们已经提供了详细阐述更复杂的项目的基础,其中涉及到异步工作。然而,如果我们通常的目标是支持 OpenCL 的 GPU,我们有好消息:在下一章,我们将介绍 TBB 的opencl_node
特性,它提供了一个更友好的界面来让 GPU 为我们工作!
更多信息
以下是我们推荐的一些与本章相关的额外阅读材料:
-
赫伯·萨特(Herb Sutter),《免费的午餐结束了:软件并发性的根本转变》,
www.gotw.ca/publications/concurrency-ddj.htm
。 -
约翰·麦卡尔平,
www.cs.virginia.edu/stream/ref.html
。 -
大卫凯利,佩哈德米斯特里,达纳沙,张东平。使用 OpenCL 2.0 进行异构计算。摩根·考夫曼 2015。
开放存取本章根据知识共享署名-非商业-非专用 4.0 国际许可协议(http://Creative Commons . org/licenses/by-NC-nd/4.0/)的条款进行许可,该协议允许以任何媒体或格式进行任何非商业使用、共享、分发和复制,只要您适当注明原作者和来源,提供知识共享许可协议的链接,并指出您是否修改了许可材料。根据本许可证,您无权共享从本章或其部分内容派生的改编材料。
本章中的图像或其他第三方材料包含在本章的知识共享许可中,除非在材料的信用额度中另有说明。如果材料不包括在本章的知识共享许可中,并且您的预期使用不被法律法规允许或超出了允许的使用范围,您将需要直接从版权所有者处获得许可。
十九、Steroids 上的流程图:penCL 节点
async_node
是否让你渴望更多?如果是这样,这就是你的篇章。在这里,我们将介绍一个高级流程图类opencl_node
,它试图隐藏 OpenCL 设备的硬件细节和编程细节。为什么选择 OpenCL?原因有很多,仅举几个例子:OpenCL 是由一个大型联盟的成员贡献的开放标准,它被设计成一个独立于平台的 API,它的目标是灵活地发展以满足更新的需求。例如,OpenCL 一直是 C(不是 C++)的扩展,但是最新的 OpenCL 2.2 版本增加了对 C++14 子集的支持,包括类、lambda 表达式、模板等等。
这还不够吗?好吧,再来一个。对我们来说,所有原因中最突出的是可以使用 OpenCL 的平台的数量和种类。从笔记本电脑和台式机开始,这些系统中超过 95%的处理器都包含支持 OpenCL 的集成 GPU(通常来自英特尔或 AMD)。在移动领域,在大多数智能手机和平板电脑的核心,我们发现了一个片上系统,SoC,具有支持 OpenCL 的 GPU(是的,从 TBB 仓库我们也可以获得 Android 的 TBB 二进制文件)。这些例子看起来已经足够有说服力了,但还有更多!在嵌入式领域,多年来,我们已经能够购买和开发异构板,包括 OpenCL 可编程 FPGA(来自 Intel-Altera 和 Xilinx)。在服务器领域,在撰写这些文章时,英特尔的目标是采用 FPGA PCIe 卡和英特尔至强可扩展处理器 6138P 的数据中心,该处理器包括片上英特尔 Altera Arria 10 FPGA,当然,OpenCL 是受支持的编程模型之一。此外,OpenCL 代码还可以在许多 CPU 和其他类型的加速器上运行,如 Xeon Phi。
但是如果 OpenCL 不能满足您的需求,TBB 建筑师事务所也考虑了支持其他编程模型的可能性。他们将加速器编程模型的底层细节抽象成一个叫做工厂的模块。事实上,opencl_node
是用特定工厂实例化一个名为streaming_node
的通用类的结果。然后,工厂定义必要的方法来上传/下载数据到加速器和启动内核。也就是说,opencl_node
是将streaming_node
类与 OpenCL 工厂结合的结果。开发相应的工厂就可以支持更新的编程模型。
现在,这是一个相当长的章节,涵盖了几个概念(opencl_node
、opencl_program
、,
、??)、OpenCL 内核的参数和范围、子缓冲区等。)因此意味着陡峭的学习曲线。但是我们将从简单的开始,逐步提升到更复杂的类和例子(就像我们一直试图做的那样)。正如我们在图 19-1 中所描绘的,我们将从一个简单的Hello World
——类似于使用opencl_node
的例子开始,随后实现与前一章相同的三元矢量计算,但是现在使用我们新的高级玩具。如果你想把最后的攀登留到顶峰,你可以在那里停止阅读。另一方面,如果你是一个有经验的攀岩者,在本章的最后,我们会先睹为快更高级的特性,比如微调 OpenCL NDRange
和内核规范。
图 19-1
描绘本章的学习曲线
Hello OpenCL_Node 示例
这次让我们从结尾开始。这是我们第一个示例的输出:
Hello OpenCL_Node
Bye! Received from: OPENCL_NODE
这两行是运行图 19-2 所示的流程图的结果,其中的气泡标识了由图中三个节点中的每一个打印的字符串。
图 19-2
流程图以“Hello OpenCL_Node
”为例
中间的节点gpu_node
,是一个opencl_node
,打印出OpenCL_Node\n
。为此,它将被配置为运行存储在hello.cl
文件中的以下 OpenCL 内核:
hello.cl
文件包括cl_print()
内核的定义,该内核将由流程图的一个特殊节点,一个opencl_node
执行。如果我们仔细看看内核函数,它实际上打印了作为输入参数出现的任何字符数组。此外,为了产生明显的影响,内核还通过只大写小写字母来改变字符串。参数的char *str
声明之前的global
关键字声明字符数组应该存储在 OpenCL 全局内存中。对于这里的问题(即过于简化),这意味着字符串存储在内存的一个区域中,可以由 CPU 和 GPU“以某种方式”读取和写入。在集成 GPU 的常见情况下,全局内存只是位于主内存中。这意味着opencl_node
应该接收一个字符数组作为参数。在我们的例子中,这个字符数组包含的字符是"OpenCL_Node
\n "。正如您可能已经猜到的,这个消息来自第一个节点in_node
。对,指向字符串的指针(图 19-2 中的a
)从in_node
飞到gpu_node
,在没有用户干预的情况下,在 CPU 上初始化的字符串最终到达 GPU。什么消息到达out_node
?同样,指针a
离开gpu_node
并以名称m
进入out_node
。最后,图中的最后一个节点打印出了“Bye! Received from: OPENCL_NODE
”,我们注意到了字符串的变化,也注意到了在 GPU 上处理的字符串已经可以被 CPU 访问了。现在,我们都渴望实际实现的细节,所以它们在图 19-3 中。
图 19-3
构建“Hello OpenCL_Node
”示例的流程图
就这样!注意,GPU 节点配置只需要三行 C++ 代码。是不是很整洁?
放弃
在写这一章的时候,TBB 的最新版本是 2019 年。在这个版本中,opencl_node
仍然是一个预览功能,这实质上意味着
-
它可能会发生变化。如果您依赖代码中的预览功能,请在更新到较新的 TBB 版本时仔细检查它是否继续工作。在最坏的情况下,预览功能甚至会消失!
-
它可能没有什么文档和支持。事实上,
opencl_node
和streaming_node
文档在网络上并不丰富。有一些博客条目 1 说明了这个特性,但是它们已经有 3 年的历史了,而且 API 的一部分也已经改变了。 -
必须明确启用它(即,默认情况下它是关闭的)。为了在我们的代码中使用
opencl_node
,我们必须添加这三行代码:
使用这个头文件的额外好处是你不需要手动包含tbb/flow_graph.h
或者 OpenCL 头文件,因为它们已经包含在flow_graph_opencl_node.h
中了。实际上,这个头文件和博客条目是目前我们关于这个特性提供的类和成员函数的最可靠的信息来源。这一章应该被认为是对包含在opencl_node
头文件中的 1050 行代码的简单介绍。
好吧,我们一点一点来。如果你记得上一章的例子,第一个in_node
看起来很熟悉。为了提醒我们,可以说:( 1)lambda(&
的输入参数实际上是对将被发送到任何连接节点的消息的引用;(2)只有一条消息离开in_node
,因为在第一次调用后它返回 false 以及(3) in_node.activate()
实际上唤醒节点并触发该单个消息。但是等等,在这个节点中有一些新的东西是我们必须注意的!离开in_node
的消息必须在 GPU 可访问的内存区域结束,这就是为什么参数a
不仅仅是一个字符数组,而是对一个buffer_t
的引用。就在定义in_node
之前,我们看到buffer_t
是 OpenCL chars ( cl_char
)的一个opencl_buffer
:
opencl_buffer
是我们将在本章中看到的第一个opencl_node
助手类,但是还有更多。它是一个模板类,抽象了强类型线性数组,封装了主机和加速器之间的内存事务逻辑。我们使用类的构造器来分配一个opencl_buffer<T>
,就像我们的例子中的行a = buffer_t{sizeof(str)}
,或者通过用
在这两种情况下,我们最终都会分配一个cl_char
的opencl_buffer
。我们现在使用的 OpenCL 工厂版本基于 OpenCL 1.2,并利用了零拷贝缓冲区方法。这意味着,在内部,当调用opencl_buffer
构造器时,OpenCL 函数clCreateBuffer
被调用,它的一个参数是CL_MEM_ALLOC_HOST_PTR
。正如我们在前一章简单解释的那样,缓冲区是在 GPU 空间上分配的,但是 CPU 可访问的指针(缓冲区的 CPU 视图)可以使用映射函数(clEnqueueMapBuffer
)获得。为了将缓冲区的控制权交还给 GPU,OpenCL 提供了一个unmap
函数(clEnqueueUnmapMemObject
)。在集成 GPU 的现代芯片上,map 和unmap
函数很便宜,因为不需要实际的数据副本。对于这些情况,map
和unmap
函数负责保持 CPU 和 GPU 缓存与存储在全局内存(主内存)中的副本一致,这可能意味着也可能不意味着 CPU/GPU 缓存刷新。好消息是,所有这些低级的杂务都不关我们的事了!可以开发具有更好特性或支持其他加速器的新工厂,我们可以通过简单地重新编译我们的源代码来使用它们。考虑一下,如果明天公开一个 OpenCL 2.0 工厂,并且我们的加速器实现了细粒度的缓冲 SVM。仅仅通过使用新的 OpenCL 2.0 工厂而不是 1.2 工厂,我们将免费获得性能提升(因为现在map
和unmap
操作是不必要的,CPU 和 GPU 之间的缓存一致性由硬件自动保持)。
哎呀,抱歉让我们的思绪飘了一会儿。让我们回到正题。我们在图 19-3 中解释了我们例子中的source_node
(是的,几段之前)。这个source_node
、in_node
,只是用字符串OpenCL_Node\n
初始化一个chars
、str
的数组,分配适当大小的opencl_buffer
、a
,并使用std::copy_n
STL 算法将字符串复制到那个缓冲区。就这样。当这个source_node
的 lambda 结束时,引用opencl_buffer
的消息将从in_node
飞到gpu_node
。
现在,记住配置gpu_node
所需的行:
第一行使用了我们在本章中提到的第二个opencl_node
助手类:opencl_program
类。在这一行中,我们创建了program
对象,并将文件名hello.cl
传递给构造器,OpenCL 内核cl_print
就存储在这里。如果我们想提供一个预编译的内核或者内核的 SPIR (OpenCL 中间表示)版本,还有其他的opencl_program
构造器可用。为了不让人分心,并专注于我们的例子,我们将在后面讨论这些替代方法。
第二行创建了类型为opencl_node<tuple<buffer_t>>
的gpu_node
。这意味着gpu_node
接收类型为buffer_t
的消息,完成后,它发出类型为buffer_t
的消息。对于单个参数/端口,我们真的需要一个元组吗?嗯,opencl_node
被设计为从前面的节点接收几个消息,并向图中后面的节点发送几个消息,这些消息被打包到一个元组中。目前,接口中没有针对单个输入和输出的特殊情况,因此我们需要在这种情况下使用单个元素元组。关于opencl_node
端口和内核参数之间的对应关系,默认情况下,opencl_node
将第一个输入端口绑定到第一个内核参数,第二个输入端口绑定到第二个内核参数,依此类推。后面还会谈到其他的可能性。
我们真的需要为每个传入的消息发送一个传出的消息吗?嗯,opencl_node
被设计成支持这种最大连接性(每个输入端口一个输出端口),如果输入少于输出,或者相反,我们总是可以保持相应的端口不连接。我们真的需要对输入和输出使用相同的数据类型吗?嗯,就目前的工厂来说,是的。如果输入端口 0 是类型T
,输出端口 0 也是同样的T
类型(指定参数类型的元组不区分输入和输出)。
注意
支持opencl_node
实现决策的主要原因是每个opencl_node
的端口都有可能被映射到每个 OpenCL 内核参数中。对于一个“输入-输出”参数,在输入和输出都有它当然是有意义的。对于一个“out”参数,我们仍然需要传入要写入的对象,因此需要一个输入来匹配输出——否则opencl_node
将需要分配对象,但它没有。最后,对于一个“in”参数,让它在输出端可用可以让我们转发值,也就是说,不加修改地将它传递给下游节点。所以,最实际的事情就是把所有的论点都放进去。我们相信,如果我们将 OpenCL 节点的 tuple 视为一个参数列表,那么这是有意义的,我们可以将边连接到任何参数,以在执行之前/之后设置/获取值。对于“in
”参数,相应的发出值不变。对于一个“out
”参数,我们提供了要写入的内存,并在稍后获取值。对于“in-out
”,我们发送值并接收修改后的值。
请记住,OpenCL 节点是一个预览功能。TBB 开发者渴望预览功能的输入——这就是为什么他们毕竟是预览功能。他们希望收集好的和坏的信息,这样他们就可以花时间完善图书馆中人们最关心的部分。这个 OpenCL 节点的预览版应该足够好,可以试用并提供反馈。如果我们对需要添加什么有强烈的意见,我们应该说出来!
现在,opencl_node
的构造器包含流图对象g
作为参数,以及应该包含在 OpenCL 程序文件中的内核函数的句柄。由于文件hello.cl
包含内核函数cl_print
,我们使用成员函数:program.get_kernel("cl_print")
。
这意味着我们可以在同一个 OpenCL 源文件中有几个内核函数,并将每个函数分配给不同的opencl_nodes
。我们真的必须用一个程序文件来解决吗?不完全是。如果我们将 OpenCL 内核分布在几个源文件中,我们可以实例化期望数量的opencl_program
对象。
最后,配置gpu_node
所需的第三行代码是gpu_node.set_range({{1}})
。这个来自opencl_node
的成员函数指定了 GPU 将要遍历的迭代空间。更正式地说,在 OpenCL 行话中,这个迭代空间被称为NDRange
,但是我们现在不要详细讨论这些细节。现在,让我们大胆地相信,set_range({{1}})
成员函数导致内核主体只被执行一次。
现在我们已经完成了source_node
(in_node
)opencl_node
(gpu_node
),我们例子中的最后一个是一个名为out_node
的常规function_node
。对应的代码是
我们看到out_node
收到了一条buffer_t
类型的m
消息。因为buffer_t
实际上是一个opencl_buffer<cl_char>
,所以调用m.begin()
会产生一个 CPU 可见的指针,指向最初在in_node
中设置的、后来被 GPU 内核修改的字符串。我们的最后一个节点只是打印这个字符串,然后死亡。
示例的其余部分是通常的流图粘合逻辑,它在节点之间形成边,唤醒源节点,并等待所有消息(在我们的示例中只有一条)通过节点。这里没什么新鲜的。
然而,在我们开始攀登我们的第一座山峰之前,我们将对我们刚刚解释的内容进行一次高级别的回顾,同时更深入地了解消息a
发生了什么,该消息诞生在 CPU 上,发送到 GPU 并在那里进行修改,然后传递到最终节点,在那里我们可以看到 GPU 内核执行的效果。我们希望图 19-4 能在这方面很好地为我们服务。
图 19-4
包含消息操作细节的示例概述
图片假设 OpenCL 工厂是基于这个标准的 1.2 版本。在这种情况下,消息a
作为opencl_buffer
被分配在 GPU 内存空间中,但是如果我们首先使用a.begin()
获得 CPU 可访问的迭代器,它也可以被写到 CPU 上。对a
的引用是离开in_node
并进入gpu_node
的端口 0 的消息(这将总是导致消息-对a
的引用-通过出发端口 0 离开)。gpu_node
的端口 0 被绑定到具有兼容类型的内核函数的第一个参数(opencl_buffer<cl_char>
可以被强制转换为char *
)。内核可以安全地访问字符串,而不会出现缓存一致性问题,因为在启动内核之前,OpenCL 工厂会负责解除缓冲区的映射。最后,对缓冲区的引用到达out_node,
,在这里字符串再次被映射,以便在 CPU 上访问和打印。
在继续之前,我们想在这里强调我们应该感到多么幸运,因为我们不必手动处理所有的 OpenCL 样板代码(平台、设备、上下文、命令队列、内核读取和编译、内核参数设置和启动、OpenCL 资源解除分配等)。).多亏了 OpenCL 工厂,所有这些现在都隐藏在引擎盖下。此外,正如我们所说的,新工厂可以使我们的代码更快,或者能够与其他加速器一起工作,只需对源代码进行很小的更改或不做任何更改。
我们在哪里运行我们的内核?
到目前为止一切顺利,对吧?但是说到 OpenCL 样板代码,控制我们在哪个设备上运行我们的opencl_nodes
的旋钮在哪里呢?在我们之前的例子中,我们说过gpu_node
正在 GPU 上运行指定的内核。还有哪里,对吗?但是如果我们在撒谎呢?令人不安,是吧?好的,让我们先看看我们的机器上是否有更多支持 OpenCL 的设备。希望只有一个单一的设备,它是一个 GPU,但我不会赌我的手指!我们将不得不嗅出它,但是我们在情感上还没有准备好编写旧式的普通 OpenCL 代码,不是吗?幸运的是,TBB OpenCL 工厂给了我们两个额外的有价值的助手类(现在已经有四个了)。这些是opencl_device
和opencl_device_list
助手类。让我们首先在流程图上下文之外使用它们,如图 19-5 所示。
图 19-5
查询 OpenCL 平台和可用设备的简单代码
首先,通过调用函数available_devices()
.
初始化一个opencl_device_list
对象devices
,该函数返回一个可迭代容器,其中包含第一平台中所有可用的 OpenCL 使能设备。是的,仅在第一个可用的平台中。 2 然后,我们从列表中弹出第一个opencl_device
、d
,查询平台名称、概要文件、版本和厂商。平台中所有可用的设备将共享这些属性。
接下来,使用for(opencl_device d:devices)
,我们遍历整个设备列表,获取并打印每个设备的名称、主版本和次版本以及设备类型。主版本和次版本信息已经由d.platform_version()
提供,但是这个返回一个字符串,而d.major_version()
和d.minor_version()
都返回一个整数。在我们写这些代码的 MacBook 上运行这些代码的输出结果,以及我们运行之前例子的地方,可以在图 19-6 中看到。
注意
函数available_devices()
实际上不是公共的,这就是我们必须使用这个错综复杂的名称空间链的原因:
tbb::flow::interface10::opencl_info::available_devices()
我们注意到,就在实现这个成员函数之前,在flow_graph_opencl_node.h
内部有一个注释声明
// TODO: consider opencl_info namespace as public API
由于这是 TBB 的一个预览功能,界面还没有完全确定下来.
考虑到这一点,以防这一考虑最终成为事实。
图 19-6
在 MacBook Pro 上运行图 19-5 的代码的结果
令人惊讶的是,一台笔记本电脑中可能有三个 OpenCL 设备!也就是说,一个英特尔 CPU 和两个 GPU,第一个集成在英特尔酷睿 i7 中,第二个是独立的 AMD GPU。请记住,OpenCL 是一种可移植的编程语言,也可以用来实现 CPU 代码。看,第一个支持 OpenCL 的设备不是 GPU,而是四核英特尔 CPU。现在,关于本章的第一个例子,内核在哪里运行?在第一点上,你是对的。默认情况下,OpenCL 工厂选择第一个可用的设备,不管它是 CPU 还是 GPU。所以…我们在撒谎!!!内核运行在伪装成 OpenCL 加速器的 CPU 上。如果我们在整本书里到处撒谎呢?想想看…那就更恐怖了(除非这是你正在读的第一章)。
好吧,我们来解决这个小麻烦。为了化险为夷,OpenCL 工厂提供了两个额外的特性:设备过滤器和设备选择器。设备过滤器用于用一组可用于内核执行的设备来初始化opencl_factory
。所有过滤的设备必须属于同一个 OpenCL 平台。有一个默认的设备过滤器类default_device_filter
,它自动从第一个 OpenCL 平台收集所有可用的设备,并返回一个包含这些设备的opencl_device_list
。就其本身而言,设备选择器,顾名思义,选择那个opencl_device_list
中的一个设备。不同的opencl_node
实例可以使用不同的设备选择器。对于每个内核执行都要进行选择,所以对于不同的调用,在不同的设备上运行opencl_node
也是可能的。默认选择器default_device_selector
从设备过滤器构建的可用设备列表中选择并返回第一个设备。
让我们的gpu_node
在真正的 GPU 上运行,而不是
我们应该使用
其中gpu_selector
是我们自定义的对象class gpu_device_selector:
gpu_device_selector gpu_selector;
而这个类呈现在图 19-7 中。
图 19-7
我们的首款定制器件选择器
协议(更正式的说法是“概念”)是,opencl_node
的第三个参数是一个函子(带有operator()
成员函数的类的对象),它返回一个设备。这样,我们可以在它的位置嵌入一个 lambda 表达式,而不是传递函子。operator()
接收一个opencl_factory
、f
,并返回一个opencl_device
。使用find_if
STL 算法,我们在满足it->type()==CL_DEVICE_TYPE_GPU
的容器devices()
中返回第一个迭代器it
。为了方便起见,我们声明了auto it
并委托给编译器去发现it
的类型实际上是
tbb::flow::opencl_device_list::const_iterator it = ...
考虑到找不到 GPU 设备的可能性,我们包括了一个返回第一个设备的回退(应该至少有一个!...没有任何设备的平台是没有意义的)。仿函数通过打印所选设备的名称并将其返回来结束。在我们的笔记本电脑中,输出将是:
注意,当该节点被激活时,新消息由gpu_node
设备选择器仿函数打印出来。这是,首先in_node
打印它的消息“Hello”并将消息传递给gpu_node
,后者在启动内核之前选择设备(打印输出的粗体字),然后运行内核。这是需要考虑的事情:流程图中的opencl_node
通常会被激活几次,所以我们最好实现尽可能最轻的设备选择器。
比如std::find_if
算法的 lambda 表达式不需要打印“找到 GPU!”消息,可以进一步简化:
现在,如果我们不喜欢必须显式添加gpu_device_selector
类的源代码的样子,我们可以用 lambda 表达式代替仿函数。这有点棘手,因为这个类的operator()
是一个模板化的函数,还记得吗?:
(据我们所知)实现 lambda 最简单的方法是依赖从 C++14 开始就有的多态 lambda。不要忘记用选项std=c++14
编译图 19-8 中的代码。
图 19-8
使用 lambda 表达式而不是仿函数进行设备选择
注意 lambda 的(auto& f)
参数,而不是我们在基于函子的替代方案中使用的(opencl_factory<DeviceFilter>& f)
。这段代码遍历devices()
容器,然后返回列表中的第二个设备,结果类似于
Available devices:
0.- Device: Intel(R) Core(TM) i7-6700HQ CPU @ 2.60GHz
1.- Device: Intel(R) HD Graphics 530
2.- Device: AMD Radeon Pro 450 Compute Engine
Running on Intel(R) HD Graphics 530
现在我们知道了我们的设备列表,并假设我们想要使用集成的 GPU,最好更改 lambda 以使其更快:
更快的替代方法是在我们第一次调用设备选择器时缓存opencl_device
。例如,在图 19-9 中,我们绘制了图 19-7 中出现的gpu_device_selector
类的修改草图。
图 19-9
第一次调用opencl_device
时缓存它的设备选择器类
这个类现在有了一个opencl_device
成员变量device
。当第一次调用operator()
时,遍历设备列表f.devices()
,找到我们想要使用的设备(在本例中,是第二个可用的设备)。然后我们将它缓存到device
变量中以备将来使用。请注意,如果可以从不同的线程同时调用该操作符,则需要进一步注意避免数据竞争。
我们希望你能保守我们对数字 19-8 和 19-9 的例子编码有多糟糕的秘密。在这些片段中,我们将设备硬编码为第二个设备,它可以在我们的测试机上工作,但在其他平台上可能会失败。实际上,如果有一个设备存储在f.devices()
容器中,取消引用*(++f.devices().cbegin())
将触发一个分段错误。这是便携性和性能之间权衡的又一个例子。如果我们不知道代码最终会在哪里运行,并且与 OpenCL 计算相比,设备选择时间可以忽略不计,那么我们最好使用图 19-7 (注释掉打印语句)的版本。
第十八章回到更现实的例子
你还记得我们在前一章介绍的三元组向量运算吗?这只是一个形式为C = A +
α *B
的基本数组操作,其中A
、B
和C
是包含vsize
浮点数的 1D 数组,α是一个标量,我们将其设置为 0.5(因为我们可以)。图 19-10 提醒我们三元组计算将根据变量offload_ratio
在 GPU 和 CPU 之间分配的方式。
图 19-10
三元组计算的异构实现
重新实现这个例子的目的有两个。首先,通过重新访问我们的老熟人,但是现在从opencl_node
的角度,我们将更好地欣赏 TBB 流图的这个更高层次特征的好处。其次,超越“Hello OpenCL_Node
”将允许我们深入研究opencl_node
类及其助手类的更高级的用法。在图 19-11 中,我们给出了我们将要实现的流程图的概述。
图 19-11
实现三元组的流程图,现在使用 OpenCL 节点
和我们前面的例子一样,source_node
( in_node
)只是触发流程图的执行,在这个例子中,传递一条值为offload_ratio
的消息。下游的下一个节点是multifunction_node (dispatch_node).
,这种节点非常灵活,可以向图中的下一个节点发送消息。我们看到dispatch_node
有五个输出端口,前四个针对gpu_node
,最后一个连接到cpu_node
。gpu_node
是一个opencl_node
,它将配置适当的三元组 GPU 内核,该内核期望数组A
、B
和C
的“GPU 视图”作为输入参数(如前一章所述,它们被称为Adevice
、Bdevice,
和Cdevice
)。然而,gpu_node
有一个额外的端口来接收将要卸载的迭代次数,这取决于offload_ratio
并且我们称之为NDRange
来遵守 OpenCL 符号。cpu_node
是一个常规的函数节点,它接收三个数组的“CPU 视图”以及offload_ratio
,这样 CPU 就可以完成它的任务。cpu_node
只有一个输入端口,所以dispatch_node
必须将 CPU 所需的四个变量打包成一个元组。gpu_node
和cpu_node
都将它们自己的结果数组C
的视图传递给join_node
,后者又用两个视图构建一个元组,并将其转发给out_node
。这个最终节点将验证计算是否正确,并打印出执行时间。事不宜迟,让我们从真正的实现开始,从图 19-12 中的数据类型定义和缓冲区分配开始。
图 19-12
三元组示例中的数据类型定义和缓冲区分配
从现在开始,buffer_f
是cl_floats
的opencl_buffer
(OpenCL 中常规浮点数据类型的对应类型)。这样,我们将Adevice
、Bdevice
和Cdevice
分配为我们三个数组的“GPU 视图”。opencl_buffer
类还公开了data()
成员函数,这是我们在这里第一次看到。该函数返回一个指向 GPU 缓冲区的 CPU 可访问指针,并负责映射缓冲区,以便 CPU 可以访问它。这允许我们初始化指针Ahost
、Bhost
和Chost
。使用 STL generate
算法,我们用 0 到 255 之间的随机数初始化数组A
和B
,使用 Mersenne Twister 生成器(正如我们在第五章中所做的)。
图的前两个节点in_node
和dispatch_node
在图 19-13 中定义。
图 19-13
三元组示例中的前两个节点in_node
和dispatch_node
算法的这一部分非常简单。我们的老朋友in_node
给offload_ratio=0.5
发了一封短信给dispatch_node
。dispatch_node
属于以下类型:
这意味着它接收一个 float ( offload_ratio
)并有五个输出端口发送对应于五个元组元素类型的消息。这个元组封装了这个多功能节点的五个输出端口的数据类型:三个数组的三个buffer_f
(这里是opencl_buffers
)、NDRange
和一个为cpu_node
打包所有信息的tuple_cpu
。
定义dispatch_node
主体的 lambda 表达式的两个输入参数是
在这里我们可以找到输入消息(offload_ratio
)和一个句柄(ports
),它可以让我们访问五个输出端口中的每一个。现在,我们使用函数get<port_number>(ports).try_put(message)
向相应的port_number
发送消息。我们只需要对这个函数进行四次调用,就可以发送 GPU 正在等待的信息。请注意,这四个调用中的最后一个调用放置了一个只有一个元素等于ceil(vsize*offload_ratio)
的 1D 数组,它对应于 GPU 上的迭代空间。使用get<4>(ports).try_put(cpu_vectors).
,单个消息通过最后一个端口到达 CPU。之前,我们已经方便地将三个向量的 CPU 视图和向量分区信息(ceil(vsize*offload_ratio)
)打包在cpu_vectors
元组中。
有什么问题吗?确定吗?我们不想落下任何读者。那好吧。让我们继续看下两个节点的实现,这是问题的核心,真正的计算发生在这里,如图 19-14 所示。
图 19-14
在三元组示例中真正肩负重任的节点:gpu_node
和cpu_node
虽然cpu_node
是图 19-14 中的第二个,但我们将首先介绍它,因为它不太需要澄清。模板参数<tuple_cpu, float*>
指出节点接收到一个tuple_cpu
并发送一个指向float
的指针。lambda 输入参数cpu_vectors
在主体中用于将指针解包为三个向量和变量start
(获得已经在dispatch_node
上计算的值ceil(vsize*offload_ratio)
)。利用该信息,a parallel_for
在范围blocked_range<size_t>(start, vsize)
中执行三元组计算,这对应于迭代空间的第二部分。
正如我们所说,GPU 负责这个迭代空间的第一部分,在这个上下文中称为NDRange=0, ceil(vsize*offload_ratio))
。GPU 内核的源代码与我们在上一章中介绍的相同,它只是接收三个数组,并对NDRange
中的每个i
进行三元运算:
![../img/466505_1_En_19_Figo_HTML.png 这些内核行在triad.cl
文件中,因此有这样一行:
图 19-14 开始。定制型tuple_gpu
包三个buffer_f
和NDRange
。据此,我们将gpu_node
声明为
它选择程序文件的内核triad
,并指定我们最喜欢的设备选择器gpu_selector
。
现在出现了一个有趣的配置细节。四条消息到达gpu_node
,我们之前提到过“opencl_node
将第一个输入端口绑定到第一个内核参数,将第二个输入端口绑定到第二个内核参数,依此类推。”但是等等!内核只有三个参数!我们又说谎了!!??好吧,这次不会。我们还说过这是默认行为,可以修改。以下是方法。
使用gpu_node.set_args(port_ref<0,2>)
,我们声明到达端口 0、1 和 2 的消息应该绑定到内核的三个输入参数(A
、B
和C
)。那NDRange
呢?在图 19-3 中的第一个例子Hello OpenCL_Node
中,我们只是使用gpu_node.set_range({{1}})
来指定可能最小的NDRange
常量值 1。但是在第二个更详细的例子中,NDRange
是可变的,来自dispatch_node
。我们可以绑定节点的第三个端口,它用set_range()
函数接收NDRange
,就像我们对行gpu_node.set_range(port_ref<3>)
所做的那样。这意味着我们可以通过端口向set_range()
传递一个常量或变量NDRange
。成员函数set_args()
应该支持同样的灵活性吧?我们知道如何将内核参数绑定到opencl_node
端口,但是通常内核参数只需要设置一次,而不是每次调用都设置。
比方说,我们的内核接收α的值,它现在是一个用户定义的参数(不像以前那样硬连接到 0.5):
然后我们可以编写如下代码:gpu_node.set_args(port_ref<0,2>, 0.5f)
,它将前三个内核参数绑定到到达端口 0、1 和 2 的数据,并将第四个参数绑定到… 0.5(哦不!又硬连线了!更严重的是,没有什么可以阻止我们传递一个变量alpha
,这个变量之前被设置为...0.5).
现在,让我们来看看最后两个节点,node_join
和out_node
,它们在图 19-15 中有详细描述。
图 19-15
异源三元组向量运算的最后两个节点node_join
和out_node
如粗体所示,node_join
接收一个buffer_f
(来自gpu_node
)和一个指向float
(来自cpu_node
)的指针。创建这个节点只是为了将这两条消息连接成一个元组,该元组将被转发到下一个节点。说到这里,下一个节点是out_node
,一个function_node
,接收join_t::output_type
类型的消息,不发送任何输出消息。注意join_t
是node_join
的类型,所以join_t::output_type
是tuple<buffer_f, float*>.
的别名实际上,lambda 的输入参数m
就有这种类型。解包元组m
的一种便捷方式是执行std::tie(Cdevice, Chost) = m
,这完全等同于
Cdevice = std::get<0>(m);
Chost = std::get<1>(m);
out_node
正文的下几行检查异构计算是否正确,首先串行计算三元数组运算的黄金版本CGold
,然后使用std::equal
算法与Chost
进行比较。由于Chost
, Cdevice.data()
,
和Cdevice.begin()
实际上都指向同一个缓冲区,所以这三个比较是等价的:
std::equal (Chost, Chost+vsize, CGold.begin())
std::equal (Cdevice.begin(), Cdevice.end(), CGold.begin())
std::equal (Cdevice.data(), Cdevice.data()+vsize, CGold.begin())
是时候结束我们的代码了。在图 19-16 中,我们添加了make_edge
调用并触发流程图的执行。
图 19-16
三元组主函数的最后一部分,在这里连接节点并调度图形
注意,虽然gpu_node
的四个输入端口连接到前面的dispatch_node
,但是只有gpu_node
的 2 号端口连接到node_join
。这个端口承载产生的Cdevice
缓冲区,所以它是我们唯一关心的端口。其他三个被忽视的端口不会觉得被冒犯。
我们花了一段时间来解释整个例子,但我们仍然需要添加一个东西。它与我们在前一章介绍的async_node
版本相比如何?我们的async_node
版本包含了 OpenCL 样板文件,它隐藏在OpenCL_Initialize()
函数中,但却是必需的,因为它让我们可以访问上下文、命令队列和内核处理程序。如果我们使用cl.h
OpenCL 头文件,这个async_node
版本有 287 行代码(不包括注释和空行),或者使用cl.h
头文件的 cl.hpp C++ 包装器有 193 行代码。这个基于opencl_node
特性的新版本进一步将源文件的大小减少到只有 144 行代码。
细节决定成败
我们这些以前开发过 OpenCL 代码的人知道,如果我们直接使用原始 OpenCL 库,我们可以“享受”相当大的自由度。乍看之下,这种灵活性并没有体现在opencl_node
中。怎样才能定义一个多维的NDRange
?除了NDRange
的全局尺寸,我们如何指定局部尺寸?我们如何提供一个预编译的内核来代替 OpenCL 源代码呢?也许问题是我们还没有涵盖所有可用的配置旋钮。让我们开始回答这些问题。
启动内核所需的主要 OpenCL 函数是clSetKernelArg
(如果我们使用 OpenCL 2.x 共享虚拟内存指针,则为clSetKernelArgSVMPointer
)和clEnqueueNDRangeKernel
。这些函数在 OpenCL 工厂中被内部调用,我们可以控制将哪些参数传递给它们。为了说明opencl_node
成员函数和助手函数如何被转换成原始 OpenCL 调用,我们放大了图 19-17 中的opencl_node
。
图 19-17
opencl_node
函数和本地 OpenCL 调用之间的内部和对应关系
在这个图中,我们使用前面三元组示例中的gpu_node
,其中我们配置了一个opencl_node
来接收三个opencl_buffers
和NDRange
(总共四个进出节点的端口)。正如我们在几页前解释的那样,由于gpu_node.set_args(port_ref<0,2>, alpha)
,我们清楚地说明了携带A
、B
和C
向量的前三个输入端口(0、1 和 2)应该绑定到内核的前三个参数,内核的最后一个参数(乘法因子α)静态绑定到变量alpha
,该变量不来自图的前面的节点。现在,我们已经获得了进行图 19-17 中所示的四个clSetKernelArg()
调用所需的所有信息,这四个调用依次发挥它们的魔力,使这四个参数作为输入出现在kernel void triad(...)
OpenCL 函数中。
现在,让我们看看如何适当地配置clEnqueueNDRangeKernel
调用。这是最复杂的 OpenCL 调用之一;这需要我们在图 19-18 中列出的九个参数。然而,这不是一本 OpenCL 初级读本,对于本章来说,只讨论第二到第六个参数就足够了。用变量“kernel
”标识的一个将在后面讨论,为了理解其他四个,我们必须更深入地研究 OpenCL 的基本概念之一:NDRange
。
图 19-18
OpenCL clEnqueueNDRangeKernel
调用的签名
NDRange
概念
一个NDRange
定义了一个独立工作项的迭代空间。这个空间可能是三维的,但也可能是 2D 或 1D。在我们的三元组示例中,NDRange
是 1D。图 19-17 和 19-18 中clEnqueueNDrangeKernel
调用中的参数dim
应相应地包含 1、2 或 3,并将由gpu_node.set_range()
调用正确设置。在图 19-17 的例子中,这个set_range()
调用指出NDRange
信息从图的前一个节点到达gpu_node
的端口 3。NDRange
信息应该在一个或者可选的两个容器中,这两个容器提供了begin()
和end()
成员函数。许多标准 C++ 类型都提供了这些成员函数,例如std::initializer_list
、std::vector
、std::array
和std::list
。如果我们只指定一个容器,opencl_node
只设置clEnqueueNDRangeKernel()
函数的global_work_size
参数(在图 19-17 和 19-18 中用变量global
标识)。否则,我们也指定第二个容器,opencl_node
也设置local_work_size
参数(图 19-17 和 19-18 中的local
)。
注意
正如我们所说的,NDRange global_work_size
定义了将由加速器执行的并行迭代空间。使用 OpenCL 俚语,这个空间中的每个点都被称为一个工作项(如果您熟悉 CUDA,它相当于一个 CUDA 线程)。因此,工作项目可以在不同的加速器计算单元 CUs 上并行处理,相应的计算由内核代码定义,也就是说,如果我们的内核函数包括C[i]=A[i]+B[i],
,这是将应用于该 1D 迭代空间的每个工作项目i
的表达式。
现在,工作项被分组为所谓的工作组(或者使用 CUDA 符号的块)。由于架构实现的细节,属于同一个工作组的工作项之间的联系更加紧密。例如,在 GPU 上,可以保证在单个 GPU 计算单元上调度一个工作组。这意味着我们可以用 OpenCL barrier 同步单个工作组的工作项,这些工作项共享一个称为“本地内存”的每 CU 内存空间,它比全局内存快。
参数local_work_size
指定了工作组的规模。如果没有提供,OpenCL 驱动程序可以自动计算推荐的local_work_size
。然而,如果我们想要强制一个特定的工作组规模,我们必须设置local_work_size
参数。
这里的一些例子将使它变得非常清楚。假设我们有维度为h x w
的 2D 数组A
、B
和C
,我们想计算矩阵运算 C=A+B。虽然矩阵是二维的,但在 OpenCL 中,它们是作为指向行为主的线性化 1Dcl_mem
缓冲区的指针传递给内核的。这并不妨碍我们从 2D 指数计算 1D 指数,所以内核看起来像这样
尽管表达相同内容的奇特方式使用了int2
类型,读作
int2 gId = (int2)(get_global_id(0), get_global_id(1));
C[gId.y*w+gId.x] = A[gId.y*w+gId.x] + B[gId.y*w+gId.x];
为了获得内核执行期间每个工作项的更多信息,我们将打印出一些附加信息,如图 19-19 所示。
图 19-19
添加两个矩阵并打印出相关工作项信息的内核示例
前三个变量gId
、lId
和grId
分别在维度x
和y
中存储每个工作项的全局 ID、本地 ID 和组 ID。接下来的三个变量gSize
、lSize
和numGrp
被设置为全局大小、局部大小和工作组数量。第一个 if 条件仅由具有全局ID (0,0).
的工作项满足,因此只有该工作项打印出不同大小和数量的组,这对于所有工作项都是相同的。第二个printf
语句由每个工作项执行,并打印该工作项的全局、局部和组 id。当与dim = 2
、global = {4,4}
和local = {2,2}.
一起排队时,这将产生如图 19-20 所示的输出
图 19-20
图 19-19 配置dim=2
、global={4,4}
、local={2,2} --set_range({{4, 4}, {2, 2}})--
时的内核输出
在这个图中,我们用一个彩色的方框描述了每个工作项。有 16 个工作项排列在一个 4×4 的网格中,我们用四种不同的颜色来标识每个工作组。由于局部尺寸是{2,2}
,每个工作组是一个 2×2 的子空间。难怪组的数量是 4,但是为了给这一章提供一些形式主义,我们在这里添加了一些我们可以很容易证明的不变量:
numGrp.x = gSize.x/lSize.x
0 <= gId.x < gSize
0 <= lId.x < lSize
gId.x = grId * lSize.x + lId.x
同样,对于.y
坐标(或者甚至是 3D 空间中的.z
)
现在,我们如何指定一个opencl_node
的全局和局部大小?到目前为止,我们只是在本章前面的例子中使用了gpu_node.set_range({{<num>}})
。这将转化为dim=1
、global={<num>}
和local=NULL
,这导致 1D NDRange
的本地大小由 OpenCL 驱动程序决定。
在一般情况下,我们可能需要global={gx, gy, gz}
和local={lx, ly, lz}.
实现这一点最简单的方法是使用
gpu_node.set_range({{gx, gy, gz},{lx, ly, lz}});
然而,正如我们所说的,任何可以用begin()
成员函数迭代的容器也将满足我们的需求。例如,一种更复杂的表达方式是
结果范围的维度与容器中的元素数量一样多,每个维度的大小都设置为相应的元素值。这里的警告是为全局和局部容器指定相同的维度。
为了让事情变得有趣,我们必须添加可以启动图 19-19 的内核的 TBB 驱动程序代码。我们所知道的最简洁的方法是建立一个只有一个opencl_node
的图,如图 19-21 所示。
图 19-21
opencl_node
孤立地练习
看到了吗?只需几行代码,我们就可以开始运行添加两个矩阵 A 和 b 的 OpenCL 代码。请注意,opencl_node
、gpu_node
只有一个端口port<0>
,它绑定到内核的第三个参数 matrix C
,它携带内核中执行的计算的结果。使用set_args
成员函数直接传递输入矩阵A
和B
以及矩阵宽度w
。还要注意的是,opencl_node
必须至少有一个端口,并且只有当一个消息到达这个入口端口时,它才被激活。实施gpu_node
的替代方案如下:
其中gpu_node
在port<0>,
上接收Cdevice
,在port<1>
上接收NDRange
,其余的内核参数由set_range()
成员函数指定。到达和离开gpu_node
的port<1>
的消息类型是tbb::flow::opencl_range
(到目前为止是第无数个opencl_node
助手类!),我们依靠try_put()
来传递一个用两个容器初始化的opencl_range
对象。
玩弄偏移
我们留下了clEnqueueNDRangeKernel
函数的另外两个参数(见图 19-18 )。一个是 offset 参数,可以用来跳过迭代空间开始处的一些第一个工作项。在 OpenCL 工厂的当前实现中,这个偏移量是硬连线到{0,0,0}.
的,没什么大不了的。有两种可能的解决方法来克服这个限制。
第一种方法是将偏移量传递给内核,并在索引数组之前将其添加到全局 ID 中。例如,对于一维的C=A+B
操作,我们可以这样写
当然,我们可以修改NDRange
来避免数组溢出。虽然实用,但不是一个超级优雅的解决方案。那么哪个是超级优雅的解决方案呢?嗯,我们可以使用opencl_subbuffer
类来实现相同的结果。例如,如果我们只想添加向量A
和B
的一个子区域,我们可以保留一个简单版本的向量添加内核:
但是将以下参数传递给set_args()
成员函数:
Adevice.subbuffer(offset, size)
同样,对于Bdevice
和Cdevice
。创建Cdevice
子缓冲区的另一种方法是调用
tbb::flow::opencl_subbuffer<cl_float>(Cdevice, offset, size)
指定 OpenCL 内核
最后,我们必须花些时间来讨论kernel
的论点(见图 19-18 )。到目前为止,我们使用 OpenCL 源文件来提供我们的内核。在图 19-21 的最后一个例子中,我们再次使用了opencl_program
类:
这相当于更显式的构造器:
这是提供内核函数的常用方法,一方面,它需要在运行时编译源代码,另一方面,它提供了可移植性,因为源代码将为所有可用的设备编译(在opencl_program
构造时只编译一次)。在内部,OpenCL 工厂依赖于 OpenCL 函数clCreateProgramWithSource
和clBuildProgram
。
如果我们确信不需要将我们的代码移植到任何其他平台,和/或如果对于生产版本,我们需要最后一点性能,我们也可以预编译内核。例如,借助英特尔 OpenCL 工具链,我们可以运行
ioc64 -cmd=build -input=my_kernel.cl -ir=my_kernel.clbin
-bo="-cl-std=CL2.0" -device=gpu
它生成预编译文件my_kernel.clbin
。现在,我们可以使用
当将这种类型的文件传递给opencl_program
构造器时,工厂内部使用clCreateProgramWithBinary
来代替。另一种可能性是使用opencl_program_type::SPIR
提供内核的 SPIR 中间表示。要生成 SPIR 版本,我们可以使用
ioc64 -cmd=build -input=my_kernel.cl -spir64=my_kernel.spir
-bo="-cl-std=CL1.2"
在这两种情况下,ioc64
编译器都会提供一些有用的信息。最后一次运行的输出如下所示
Using build options: -cl-std=CL1.2
OpenCL Intel(R) Graphics device was found!
Device name: Intel(R) HD Graphics
Device version: OpenCL 2.0
Device vendor: Intel(R) Corporation
Device profile: FULL_PROFILE
fcl build 1 succeeded.
bcl build succeeded.
my_kernel info:
Maximum work-group size: 256
Compiler work-group size: (0, 0, 0)
Local memory size: 0
Preferred multiple of work-group size: 32
Minimum amount of private memory: 0
Build succeeded!
这个输出告诉我们关于这个特定内核的最大工作组大小 256,以及工作组大小的首选倍数 32。
更多关于设备选择的信息
在上一节中,我们意识到我们用来进行实验的笔记本电脑包括两个 GPU。让我们看一个简单的例子,在这个例子中,我们在同一个流程图中使用了它们。在图 19-22 中,我们链接了两个opencl_nodes
以便第一个计算C=A+B
并将C
发送给下一个执行C = C – B
的。当两个节点都完成时,我们检查常规function_node
中的C == A
。数组尺寸为rows
× cols
。
图 19-22
两个opencl_node
的例子,每个配置使用不同的 GPU
在我们的笔记本电脑上,我们已经知道设备列表f.devices()
包括三个设备,第二个和第三个是两个 GPU。这样,我们可以安全地使用f.devices().begin() +1
和+2
来获得指向每个 GPU 的迭代器,正如我们在图 19-22 的两个opencl_node
定义的装箱语句中看到的。除了针对不同的 GPU,每个opencl_node
都被配置为运行程序的两个不同内核fig_19_23.cl: cl_add
和cl_sub
。从gpu_node1
流向gpu_node2
的信息就是opencl_buffer Cdevice
。在 OpenCL 工厂内部,数据移动被最小化,例如,如果一个opencl_buffer
必须由映射到同一 GPU 的两个连续的opencl_nodes
访问,则在图形的第一个 CPU 节点尝试访问相应的缓冲区(通过使用opencl_buffer.begin()
或opencl_buffer.data()
成员函数)之前,分配在 GPU 上的数据不会被移动到 CPU。
在图 19-23 中,我们展示了程序fig_19_23.cl
,包括前面代码中引用的两个内核。注意,我们没有将行宽作为第四个参数传递,而是使用包含相同值的gSz.x
。
图 19-23
fig_19_23.cl
的内容,我们看到两个内核,每个内核都是从不同的opencl_node
调用的
在我们的笔记本电脑上运行图 19-22 的代码产生的输出如下:
Running gpu_node1 on Intel(R) HD Graphics 530
Running gpu_node2 on AMD Radeon Pro 450 Compute Engine
gSz.x=4, gSz.y=4
gSz.x=4, gSz.y=4
也可以用一个opencl_node
来改变 OpenCL 设备,每次调用节点时工作都被卸载到这个设备上。图 19-24 的例子显示了一个被调用三次的opencl_node
,对于每一次调用,不同的设备被用于运行一个简单的内核。
图 19-24
一个opencl_node
就可以改变每次调用的目标加速器
代码使用初始化为0
的原子变量device_num
。对gpu_node
的每次调用返回不同的设备,循环遍历所有设备(在我们的平台中有三个)。以及以下内核:
产生的输出是
Iteration: 0
Iteration: 1
Iteration: 2
Running on Intel(R) Core(TM) i7-6700HQ CPU @ 2.60GHz
Running on Intel(R) HD Graphics 530
Running on AMD Radeon Pro 450 Compute Engine
A[0]=1
A[0]=2
A[0]=3
其中我们可以证实数组Adevice
的元素在gpu_node
的三次连续调用中已经增加了三次,并且相应的内核已经在三个不同的 OpenCL 设备上执行。
关于订单的警告是适当的!
我们应该注意的最后一个警告是,当从几个节点提供服务时,消息到达一个opencl_node
的顺序。例如,在图 19-25 中,我们展示了一个流程图g
,它包括一个由两个功能节点filler0
和filler1
提供的gpu_node
。每个“填充器”发送 1000 个缓冲区,b
,每个缓冲区有 10 个整数,形式为{i,i,i,…,i},
,范围从 1 到 1000。接收方gpu_node
接收两个消息作为b1
和b2
,并调用一个 OpenCL 内核,就像这样简单:
正如我们看到的,它基本上是乘以b1[i]=b1[i]*b2[i]
。如果b1
和b2
相等(等于{1,1,1,…}
,或者{2,2,2,…}
,等等。),我们应该在输出端得到 1000 个平方输出的缓冲器({1,1,1,…}
,然后是{4,4,4,…}
,等等)。正确确定吗?我们不想说谎,所以为了以防万一,让我们在图的最后一个节点checker
中仔细检查一下,它验证了我们的假设。
图 19-25
两个功能节点向opencl_node
提供缓冲区,这些缓冲区将在 GPU 上相乘
图 19-26 中列出了实现上图的代码。我们同意乔治·萧伯纳的观点:“说谎者的惩罚丝毫不在于他不被人相信,而在于他不能相信任何人。”作为骗子鉴赏家,我们在代码中使用了一个专门用来捕捉骗子的 try-catch 结构。
图 19-26
与图 19-25 中描绘的图形相对应的源代码
我们首先将buffer_i
定义为整数的opencl_buffer
。两个“填充器”接收一个整数i
,并用 10 个i
填充一个buffer_i
,然后发送到gpu_node
。用于配置opencl_node
的三行代码对我们来说太基础了,不需要进一步阐述。最后一个节点是检查器,如果在 GPU 上处理的缓冲区中接收的任何值不是平方整数,它将抛出异常。在制作边缘之后,1000 次迭代循环使两个填充器工作。现在,关键时刻到了,结果是
Liar!!: 42 is not a square of any integer number
好吧,我们被抓了!显然,6*7
是在 GPU 上计算的,而不是在6*6
或7*7
上。为什么呢?答案是我们没有采取足够的措施来确保到达gpu_node
的消息被正确配对。记住“填充符”的主体是由任务执行的,我们不能假定任务执行的任何特定顺序。
幸运的是,opencl_node
带有一个方便的特定类型的键匹配特性,这将扭转局面。我们在图 19-27 中使用了这个特性。
图 19-27
修正图 19-26 的代码
基本上,现在的buffer_i
是一个继承自opencl_buffer<cl_int>
的新类,增加了一个int my_key
成员变量和一个返回该键的key()
成员函数。现在填充器必须使用不同的构造器(buffer_i b{N,i}
),但更重要的是,opencl_node
接收第二个模板参数(key_matching<int>
)。这将自动指示opencl_node
调用key()
函数,并等待具有相同键值的消息被传递到所有输入端口。搞定了。如果我们用这些小的修改来运行我们的代码,我们将会看到现在我们已经被宣判伪证罪不成立了!
摘要
在这一章中,我们介绍了 TBB 流图的opencl_node
特征。我们从一个简单的Hello OpenCL_Node
例子开始,它代表了对opencl_node
的初步了解,涵盖了这个类的基础知识。然后我们开始深入研究一些助手类,比如opencl_device
对象的容器opencl_device_list
,以及设备过滤器和设备选择器实体。为了说明其他助手类并给出一个更复杂的例子,我们还使用一个opencl_node
实现了三元向量运算,以处理部分计算,而其余部分在 CPU 内核上同时处理。在那里,我们更好地介绍了opencl_buffer
助手类和opencl_node
类的set_range
和set_args
成员函数。NDRange
概念以及如何设置全局和局部 OpenCL 大小几乎需要一个章节,在这里我们还解释了如何使用opencl_subbuffer
类和其他变体来提供内核程序(预编译或 SPIR 中间表示)。接下来,我们介绍了两个例子,说明了如何将流程图的不同opencl_node
映射到不同的设备上,或者甚至如何在每次调用时更改opencl_node
卸载计算的设备。最后,我们描述了当一个opencl_node
来自不同的节点时如何避免排序问题。
最后一个免责声明。也许最后我们真的在撒谎。在写这一章的时候,opencl_node
仍然是一个预览功能,所以它可能会被修改。经过 3 年的发展,我们不期望有大的变化,但我们不能承诺这一点。如果这样的变化在未来的版本中结束,我们保证会写这一章的更新版本!你相信我们吗?
更多信息
以下是我们推荐的一些与本章相关的额外阅读材料:
-
Alexei Katranov,Opencl_node 概述。英特尔开发人员专区博客中的系列文章:
https://software.intel.com/en-us/blogs/2015/12/09/opencl-node-overview
。 -
大卫凯利,佩哈德米斯特里,达纳沙,张东平。使用 OpenCL 2.0 进行异构计算。摩根·考夫曼 2015。
图中的徒步图标 19-1 由来自 www.flaticon.com
的 Scott de Jonge 制作。
开放存取本章根据知识共享署名-非商业-非专用 4.0 国际许可协议(http://Creative Commons . org/licenses/by-NC-nd/4.0/)的条款进行许可,该协议允许以任何媒体或格式进行任何非商业使用、共享、分发和复制,只要您适当注明原作者和来源,提供知识共享许可协议的链接,并指出您是否修改了许可材料。根据本许可证,您无权共享从本章或其部分内容派生的改编材料。
本章中的图像或其他第三方材料包含在本章的知识共享许可中,除非在材料的信用额度中另有说明。如果材料不包括在本章的知识共享许可中,并且您的预期使用不被法律法规允许或超出了允许的使用范围,您将需要直接从版权所有者处获得许可。
二十、NUMA 架构上的 TBB
关心性能的高级程序员知道利用本地性是最重要的。谈到局部性,缓存局部性是第一个想到的,但是在许多情况下,对于运行在大型共享内存架构上的重型应用程序,还应该考虑非统一内存访问(NUMA)局部性。众所周知,NUMA 传达了这样一个信息:内存组织在不同的存储体中,一些内核对一些“近”存储体的访问速度要快于对“远”存储体的访问速度。更正式地说, NUMA 节点是内核、高速缓存和本地存储器的分组,其中所有内核共享对本地共享高速缓存和存储器的相同访问时间。从一个 NUMA 节点到另一个节点的访问时间可能要长得多。出现了一些问题,例如程序数据结构如何在不同的 NUMA 节点上分配,以及处理这些数据结构的线程在哪里运行(它们是靠近还是远离数据?).在本章中,我们将解决这些问题,但更重要的是,在 TBB 并行应用程序中,如何利用 NUMA 局部性。
针对 NUMA 系统的性能调优归结为四项活动:(1)发现您的平台拓扑结构,(2)了解从系统的不同节点访问内存的相关成本,(3)控制数据的存储位置(数据放置),以及(4)控制工作的执行位置(处理器关联性)。
为了防止你进一步失望(也就是现在就让你失望!),我们应该提前声明:目前,TBB 不提供利用 NUMA 本地性的高级特性。或者换句话说,在前面列出的四个活动中,TBB 只在第四个活动中提供了一些帮助,在第四个活动中,我们可以依靠 TBB task_arena
(参见第十二章)和本地task_sheduler_observer
(参见第十三章)类来识别应该限制在 NUMA 节点中的线程。对于所有其他活动,甚至对于将线程实际固定到 NUMA 节点(这是第四个活动的基本部分),我们需要使用低级的依赖于操作系统的系统调用或高级别的第三方库和工具。这意味着,即使这是一本 TBB 的书,这最后一章也不完全是关于 TBB 的。我们的目标是详细阐述如何实现利用 NUMA 局部性的 TBB 代码,即使大多数必需的活动与 TBB 没有直接关系。
既然我们已经提醒了读者,让我们把这一章分解成几个部分。我们基本上按顺序遵循前面列出的四项活动。第一部分展示了一些工具,它们可以用来发现我们平台的拓扑结构,并检查有多少 NUMA 节点可用。如果有多个 NUMA 节点,我们可以继续下一部分。在这里,我们使用一个基准来了解在我们的特定平台上利用 NUMA 本地性时潜在的加速效果。如果预期的收益令人信服,我们应该开始考虑在我们自己的代码中利用 NUMA 局部性(不仅仅是在一个简单的基准中)。如果我们认识到我们自己的问题可以受益于 NUMA 局部性,我们就可以进入问题的核心,即掌握数据放置和处理器关联性。有了这些知识,在 TBB task_arena
和task_scheduler_observer
类的帮助下,我们实现了第一个简单的 TBB 应用程序,该应用程序利用了 NUMA 局部性,并评估了相对于基线实现所获得的加速。整个过程总结在图 20-1 中。我们结束这一章,概述可以考虑用于更复杂应用的更高级和更通用的替代方案。
图 20-1
开发 NUMA 地区所需的活动
注意
如果你想知道为什么在当前版本的 TBB 中没有高级别的支持,这里有一些原因。首先,这是一个棘手的问题,高度依赖于必须并行化的特定应用程序及其运行的架构。因为没有一个放之四海而皆准的解决方案,所以由开发人员来决定最适合当前应用的特定数据放置和处理器关联性替代方案。其次,TBB 的架构师和开发人员总是试图避免在 TBB 库中使用特定于硬件的解决方案,因为它们可能会损害代码的可移植性和 TBB 的可组合性。该库不仅仅是为了执行 HPC 应用程序而开发的,在 HPC 应用程序中,我们通常可以独占访问整个高性能平台(或它的一个分区)。TBB 还应该在其他应用程序和进程也在运行的共享环境中尽力而为。在许多情况下,将线程绑定到内核并将内存绑定到 NUMA 节点会导致底层架构的利用不尽人意。在任何具有动态特性的应用程序或系统中,手动锁定被反复证明是一个坏主意。我们强烈建议不要采用这种方法,除非您确信您将在您的特定并行平台上提高您的特定应用程序的性能,并且您不关心可移植性(或者付出额外的努力来实现可移植的 NUMA 感知应用程序)。
考虑到 TBB 并行算法基于任务的特性和支持并行执行的工作窃取调度程序,让任务在接近本地内存的内核中运行似乎具有挑战性。但这不会阻止像我们这样勇敢无畏的程序员。让我们去吧!
发现您的平台拓扑
"知己知彼,百战不殆."—孙子兵法。这句千年名言告诉我们,在解决问题之前,首先要努力仔细理解我们所面临的问题。有一些工具可以方便地理解底层的 NUMA 架构。在本章中,我们将使用hwloc
和likwid
1 来收集关于架构和代码执行的信息。hwloc
是一个软件包,它提供了一种便捷的方式来查询关于系统拓扑的信息,以及应用一些 NUMA 控制,如数据放置和处理器关联性。likwid
是另一个软件包,它告知硬件拓扑结构,可用于收集硬件性能计数器,还提供一组有用的微基准,可用于描述系统特征。我们还可以使用 VTune 来分析代码的性能。虽然likwid
只适用于 Linux,但是hwloc
和 VTune 也可以很容易地安装在 Windows 和 MacOS 上。然而,由于用于说明我们代码的共享内存平台运行 Linux,除非另有说明,否则这将是我们假定的操作系统。
因为针对 NUMA 的调优需要对所使用的平台有深入的理解,所以我们将从描述两台机器的特征开始,这两台机器将贯穿本章。我们接下来介绍的两台机器被称为yuca
(来自丝兰工厂)和aloe
(来自芦荟工厂)。首先,我们可以收集这些机器的基本信息。在 Linux 上,可以使用命令“lscpu
”获得这些信息,如图 20-2 所示。
图 20-2
尤卡和芦荟的lscpu
产量
乍一看,我们看到 yuca 有 64 个逻辑内核,编号从 0 到 63,每个物理内核有两个逻辑内核(超线程又称 SMT 或同步多线程,可用),每个插槽有八个物理内核,四个插槽也是四个 NUMA 节点或 NUMA 域。就其本身而言,aloe 有 32 个禁用超线程的物理内核(每个内核只有一个线程),每个插槽有 16 个物理内核,还有两个插槽(NUMA 节点)。在lscpu
输出的最后,我们可以看到 NUMA 节点和每个节点中包含的逻辑核心的 id,但是如果我们使用来自hwloc
库的lstopo
实用程序,画面会变得更加清晰。在图 20-3 中,我们包括了执行lstopo
--no-io yuca.pdf
命令时在 yuca 上生成的 PDF 文件(参数--no-io
不考虑 I/O 设备拓扑)。
图 20-3
在 yuca 上执行lstopo
的结果
从这个图中,我们可以清楚地看到尤卡的 NUMA 组织。四个 NUMA 节点包括八个物理核心,操作系统将其视为 16 个逻辑核心(也称为硬件线程)。请注意,逻辑内核 id 取决于架构、固件(电脑上的 BIOS 配置)和操作系统版本,因此我们不能从编号中做出任何假设。对于 yuca 的特定配置,逻辑核心 0 和 32 共享同一个物理核心。现在我们更好地理解了 yuca 上lscpu
最后四行的意思:
NUMA node0 CPU(s): 0-7,32-39
NUMA node1 CPU(s): 8-15,40-47
NUMA node2 CPU(s): 16-23,48-55
NUMA node3 CPU(s): 24-31,56-63
在 yuca 上,每个 NUMA 节点有 63 GB 的本地内存,总共 252 GB。类似地,aloe 也具有 252 GB 的容量,但仅组织在两个 NUMA 节点中。在图 20-4 中,我们看到了芦荟上lstopo
输出的编辑版本。
图 20-4
对芦荟执行lstopo
的结果
我们看到,在 aloe 上,每个物理核心都包含一个逻辑核心,在第一个域中编号为 0-15,在第二个域中编号为 16-31。
了解访问内存的成本
现在我们知道了平台的拓扑结构,假设我们已经控制了处理器关联性和数据放置,让我们量化由于非本地访问而产生的开销。实际上,我们确实在已经可用的基准上控制这两个方面,比如在likwid
工具中可用的likwid
-bench
。使用这个基准,我们可以使用一个命令行运行流三元组代码(参见前两章):
likwid-bench -t stream -i 1 -w S0:12GB:16-0:S0,1:S0,2:S0
它运行用-w
参数配置的流基准的单次迭代(-i 1
)
-
S0:线程被固定到 NUMA 节点 0。
-
12 GB:三个三元组阵列占用 12 GB(每个阵列 4 GB)。
-
16: 16 个线程将共享计算,每个线程处理 31,250,000 个 double 的数据块(即 40 亿字节/每个 double/16 个线程 8 个字节)。
-
0:S0,1:S0,2:S0
:三个数组分配在 NUMA 节点 0 上。
在 yuca 上,该命令的结果报告了 8219 MB/s 的带宽。但是,更改三个数组的数据放置是很容易的,例如,更改到 NUMA 节点 1(使用0:S1,1:S1,2:S1
)将 16 个线程的计算限制在 NUMA 节点 0 中。毫不奇怪,我们现在得到的带宽只有 5110 MB/s,这意味着我们损失了 38%的带宽,这是我们在利用 NUMA 本地性时测量的。对于计算本地数据的其他配置(数据放置在线程固定的内核上)和不利用本地性的配置(数据放置在没有线程关联性的内核上),我们得到了类似的结果。在 yuca 上,所有非本地配置都会导致相同的带宽冲击,但是在其他 NUMA 拓扑上,我们会根据数据放置的位置和线程运行的位置而付出不同的代价。
在芦荟上,我们只有两个 NUMA 节点 0 和 1。将数据和计算放在同一个域中可以获得 38671 MB/s 的速度,而沿着错误的路径只能获得 20489 MB/s 的速度(几乎是一半,整整少了 47%的带宽)。我们确信,像您这样渴望阅读和学习性能编程主题的读者,现在正积极地在您自己的项目中利用 NUMA 本地性!
我们的基线示例
图 20-5 显示了我们最近一直在使用的三元组示例的并行版本,只有一个parallel_for
算法。
图 20-5
对基线算法进行评估和改进
这段代码的最后两行报告了执行时间和获得的带宽,它还没有针对 NUMA 进行优化。对于后者,访问的总字节数计算为每个数组元素的vsize
× 8 字节/double × 3 次访问(两次加载和一次存储),然后除以执行时间和一百万(转换为每秒兆字节)。在 yuca 上,当使用 32 个线程和一个千兆元素的数组运行时,会产生以下输出:
./fig_20_05 32 1000000000
Time: 2.23835 seconds; Bandwidth: 10722.2MB/s
关于芦荟:
./fig_20_05 32 1000000000
Time: 0.621695 seconds; Bandwidth: 38604.2MB/s
请注意,我们的 triad 实现获得的带宽不应与之前由likwid-bench
报告的带宽进行比较。现在,我们使用 32 个线程(而不是 16 个),根据操作系统调度程序,这些线程可以在每个内核上自由运行(而不是局限于单个 NUMA 节点)。类似地,阵列现在由操作系统按照自己的数据放置策略来放置。在 Linux 中,默认的策略 2 是“本地分配”,其中执行分配的线程决定数据的位置:如果有足够的空间,则在本地内存中,否则在远程。这种策略有时被称为“首次接触”,因为数据放置不是在分配时完成的,而是在首次接触时完成的。这意味着一个线程可以分配一个区域,但是首先访问这个区域的线程是引发页面错误的线程,并且实际上是将内存中的页面分配给该线程。在我们的图 20-5 的例子中,相同的线程分配并初始化数组,这意味着在相同的 NUMA 节点上运行的parallel_for
工作线程将具有更快的访问速度。最后一个区别是likwid-bench
用汇编语言实现三元组计算,这阻止了进一步的编译器优化。
掌握数据放置和处理器关联性
绑定数据和计算一点也不简单。主要是因为它依赖于操作系统,每个操作系统都有自己的系统调用。在 Linux 中,低级接口由libnuma
3 提供,其包括控制在 Linux 内核中实现的数据放置和处理器亲缘关系策略的功能。一个更高级的替代命令是numactl
4 命令,它解决了同样的问题,但是灵活性较差。
然而,破坏我们的 TBB 应用程序与依赖于操作系统的 NUMA 库的可移植性并不是最好的主意。已经提到的hwloc
库是一个可移植且广泛使用的替代方案。目前,TBB 没有提供自己的 API 来处理 NUMA 本地数据,但是正如我们将在后面看到的,我们可以采取一些措施来让我们的 TBB 任务在可能的时候访问本地数据。在撰写本文时,必须通过第三方库来手动控制数据放置和处理器关联性,不失一般性,我们将求助于本章中的hwloc
。这个库可以在 Windows、MacOS 和 Linux 中使用(实际上,在 Linux 中hwloc
使用下面的numactl/libnuma
)。
在图 20-6 中,我们展示了一个例子,它查询 NUMA 节点的数量,然后在每个节点上分配一些数据,稍后为每个节点创建一个线程,并将其绑定到相应的域。我们在下面使用的是hwloc
2.0.1。
图 20-6
使用hwloc
为每个 NUMA 节点分配内存和绑定线程
所有hwloc
函数的一个反复出现的参数是对象拓扑,在我们的例子中是topo
。这个对象首先被初始化,然后加载平台的可用信息。之后,我们准备从topo
数据结构中获取信息,正如我们对hwloc_get_nbobjs_by_type
所做的那样,当第二个参数是HWLOC_OBJ_NUMANODE
时,它返回 NUMA 节点的数量(其他几种类型也是可用的,如HWLOC_OBJ_CORE or HWLOC_OBJ_PU
–逻辑核心或处理单元)。NUMA 节点的数量存储在变量num_nodes
中。
该示例继续创建一个指向 doubles 的指针数组num_nodes
,该数组将在函数alloc_mem_per_node
中初始化。对alloc_thr_per_node
的函数调用创建了num_nodes
个线程,每个线程都被固定到相应的 NUMA 节点。这两个功能分别在图 20-7 和 20-8 中描述。这个例子通过释放分配的内存和topo
数据结构来结束。
图 20-7
为每个 numa 节点分配双精度数组的函数
图 20-7 显示了功能alloc_mem_per_node
的实现。关键操作是hwloc_get_obj_by_type
,当第二个和第三个参数分别为HWLOC_OBJ_NUMANODE
和i
时,它返回一个句柄给i
th
NUMA 节点对象numa_node
。这个numa_node
有几个属性,如numa_node->cpuset
(标识节点中包含的逻辑内核的位掩码)和numa_node->nodeset
(标识节点的类似位掩码)。函数hwloc_bitmap_asprintf
可以方便地将这些集合转换成字符串,我们将在程序的输出中看到后面的内容。使用nodeset
位掩码,我们可以在带有hwloc_alloc_membind
的节点中分配内存。
当运行代码直到alloc_mem_per_node
返回到主函数时,我们在 yuca 上得到的输出是
There are 4 NUMA node(s)
NUMA node 0 has cpu bitmask: 0x000000ff,0x000000ff
Allocate data on node 0 with node bitmask 0x00000001
NUMA node 1 has cpu bitmask: 0x0000ff00,0x0000ff00
Allocate data on node 1 with node bitmask 0x00000002
NUMA node 2 has cpu bitmask: 0x00ff0000,0x00ff0000
Allocate data on node 2 with node bitmask 0x00000004
NUMA node 3 has cpu bitmask: 0xff000000,0xff000000
Allocate data on node 3 with node bitmask 0x00000008
这里我们看到每个 NUMA 节点的cpuset
和nodeset
。如果我们再次刷新我们的记忆,查看图 20-3 ,我们会看到在节点 0 中我们有 8 个内核和 16 个逻辑内核,编号从 0 到 7 和从 32 到 39,在hwloc
中用位掩码0x000000ff,0x000000ff
表示。请注意,“”分隔了共享八个物理内核的两组逻辑内核。与禁用超线程的平台相比,这是 aloe 上的相应输出:
There are 2 NUMA node(s)
NUMA node 0 has cpu bitmask: 0x0000ffff
Allocate data on node 0 with node bitmask 0x00000001
NUMA node 1 has cpu bitmask: 0xffff0000
Allocate data on node 1 with node bitmask 0x00000002
在图 20-8 中,我们列出了为每个 NUMA 节点生成一个线程的函数alloc_thr_per_node
,然后使用cpuset
属性绑定它。
图 20-8
为每个 NUMA 节点创建并固定一个线程的函数
这个函数还查询 NUMA 节点的数量num_nodes
,以便稍后在创建线程的循环中迭代这个次数。在每个线程执行的 lambda 表达式中,我们使用hwloc_set_cpubind
将线程绑定到每个特定的 NUMA 节点,现在依赖于numa_node->cpuset
。为了验证锁定,我们打印线程 id(使用std::this_thread::get_id
)和运行线程的逻辑内核的 id(使用sched_getcpu
)。接下来是 yuca 上的结果,也如图 20-9 所示。
图 20-9
描绘了由于固定到 yuca 上的 NUMA 节点而导致的线程移动
Before: Thread 0 with tid 873342720 on core 33
After: Thread 0 with tid 873342720 on core 33
Before: Thread 1 with tid 864950016 on core 2
After: Thread 1 with tid 864950016 on core 8
Before: Thread 2 with tid 856557312 on core 33
After: Thread 2 with tid 856557312 on core 16
Before: Thread 3 with tid 848164608 on core 5
After: Thread 3 with tid 848164608 on core 24
这里有两件事值得一提。首先,线程最初由操作系统分配到同一个 NUMA 节点中的逻辑核心上,因为它假设它们会协作。线程 0 和 2 甚至被分配在同一个逻辑核心上。其次,线程不是固定在单个内核上,而是固定在属于同一个 NUMA 节点的整个内核集上。如果操作系统认为将一个线程移动到同一个节点的不同内核会更好,这就留有余地。为了完整起见,下面是芦荟的等效输出:
Before: Thread: 0 with tid 140117643171584 on core 3
After: Thread: 0 with tid 140117643171584 on core 3
Before: Thread: 1 with tid 140117634778880 on core 3
After: Thread: 1 with tid 140117634778880 on core 16
有兴趣的读者可以从各自的文档和在线教程中了解到hwloc
和likwid
的更多特性。然而,我们在本节中所介绍的内容足以让我们继续前进,卷起袖子,使用 TBB 实现一个 NUMA 意识版本的 triad 算法。
和 TBB 一起工作
显然,首要目标是最大限度地减少非本地访问的数量,这意味着在离存储数据的内存最近的内核上进行计算。一种非常简单的方法是在 NUMA 节点上手动划分数据,并将处理这些数据的线程限制在相同的节点上。出于教育目的,我们将首先描述这个解决方案,并在下一节简要阐述更高级的替代方案。
我们可以依靠hwloc
API 来完成数据放置和处理器关联任务,但是我们想要一个 NUMA 感知的 triad 基准的 TBB 实现。在这种情况下,管理线程的是 TBB 调度程序。从第十一章中,我们知道在tbb::task_scheduler_init
函数中创建了许多线程。此外,这个 TBB 函数创建了一个默认的竞技场,它有足够的工作线程槽来允许线程参与执行任务。在我们 triad 的基线实现中(见图 20-5),parallel_for
负责将迭代空间划分为不同的任务。所有线程将协作处理这些任务,而不管每个任务处理的迭代块以及线程运行的内核。但我们不希望它出现在 NUMA 的平台上,对吗?
我们最简单的基线三元组实施替代方案将通过执行以下三个步骤来增强实施:
-
它将在不同的 NUMA 节点上划分和分配三元组算法的三个向量 A、B 和 C。作为最简单的解决方案,静态块分区现在就可以了。在 yuca 上,这意味着 A、B 和 C 这四大块将被分配到四个节点中的每一个上。
-
它将在每个 NUMA 节点上创建一个主线程。每个主线程将创建自己的任务竞技场和自己的本地
task_scheduler_observer
。然后,每个主线程执行自己的tbb::parallel_for
算法来处理对应于这个 NUMA 节点的 A、B 和 C 的分数。 -
它会自动将连接每个竞技场的线程固定到相应的 NUMA 节点。我们为每个竞技场创建的本地
task_scheduler_observer
将会负责此事。
让我们来看看所描述的每一个要点的实现。对于主函数,我们稍微修改了我们为图 20-6 的hwloc
示例提供的函数。在图 20-10 中,我们列出了这个新示例所需的新行,在没有变化的行上使用省略号(…)。
图 20-10
NUMA 意识的主要功能是实现三和弦
程序参数thds_per_node
允许我们在每个 NUMA 节点上使用不同数量的线程。如图 20-6 所示,num_nodes
是我们使用hwloc
API 获得的 NUMA 节点数。因此,我们传递给 TBB 调度器构造器(thds_per_node-1)*(num_nodes)
而不是thds_per_node*num_nodes
,因为我们将在alloc_thr_per_node
中显式创建额外的num_nodes
主线程。
函数alloc_mem_per_node
本质上与图 20-7 中列出的函数相同,但现在用不同的大小参数调用它:doubles_per_node = vsize*3/num_nodes
,其中vsize
是三个向量的大小,所以 doubles 的总量乘以 3,但除以节点数来实现块划分。为了简洁起见,我们假设vsize
是num_nodes
的倍数。alloc_mem_per_node
完成后,data[i]
指向i
th
NUMA 节点上分配的数据。
如图 20-11 所示,alloc_thr_per_node
功能的改编版本还有其他不同之处。它现在接收数据的句柄,每个节点将要遍历的本地向量的大小,lsize
,以及用户设置的每个节点的线程数,thds_per_node
。
图 20-11
该函数为每个节点创建一个线程,以计算每个 NUMA 节点上的三元组计算
注意,在图 20-11 呈现的代码片段中,在遍历num_nodes
的i-
循环内部,有三个嵌套的 lambda 表达式:(1)对于线程对象;(2)进行task_arena::execute
会员功能;和(3)用于parallel_for
算法。在外层,我们首先将线程固定到相应的 NUMA 节点i
。
第二步是初始化在data[i]
数组中分配的指向数组A
、B
和C
的指针。在图 20-10 中,我们调用alloc_thr_per_node
作为第三个参数vsize/num_nodes
,因为在每个节点上,我们只遍历三个数组的块分布中的一个块。因此,函数的参数lsize = vsize/num_nodes
,在初始化数组A
和B
的循环中使用,并作为计算C
的parallel_for
的参数。
接下来,我们初始化一个每个 NUMA 节点的 arena,numa_arena
,它随后作为参数传递给一个task_scheduler_observer
对象,p
,并用于调用一个局限于这个 arena 的parallel_for
(使用numa_arena.execute
)。这就是我们 NUMA 感知的 triad 实现的关键。
parallel_for
将创建遍历三个向量的局部分区块的任务。这些任务将由运行在同一个 NUMA 节点内核上的线程执行。但是到目前为止,我们只有thds_per_node*num_nodes
个线程,其中num_nodes
已经被明确衍生为主线程,并被固定到不同的 NUMA 节点,但是其余的仍然可以在任何地方自由运行。全局线程池中可用的线程将各自加入一个num_nodes
竞技场。方便的是,每个numa_arena
都已经用thds_per_node
槽初始化,一个槽已经被主线程占用,其余的可供工作线程使用。我们现在的目标是将进入每个numa_arena
的第一个thds_per_node-1
线程固定到相应的 NUMA 节点。为此,我们创建了一个PinningObserver
类(从task_scheduler_observer
派生而来)并构造了一个对象p
,向构造器传递了四个参数:PinningObserver p{numa_arena, topo, i, thds_per_node}
。记住这里,i
是主线程i
的 NUMA 节点的 id。
在图 20-12 中,我们看到了PinningObserver
类的实现。
图 20-12
实现本地task_scheduler_observer
为三元组
在第十三章中介绍了task_scheduler_observer
类。它有一个预览功能,允许我们在每个任务竞技场都有一个观察者——也称为本地task_scheduler_observer
。这种观察者通过引用 arena 来初始化,就像我们在使用task_scheduler_observer{arena}
的PinningObserver
构造器的初始化列表中所做的那样。这导致进入这个特定领域的每个线程的成员函数on_scheduler_entry
的执行。该类的构造器还设置了 NUMA 节点的数量,num_nodes
和numa_node
对象,它们将为我们提供对numa_node->cpuset
位掩码的访问。构造器最后调用成员函数observe(true)
开始跟踪任务是否进场。
函数on_scheduler_entry
跟踪已经固定到原子变量thds_per_node
中的numa_node
的线程数量。这个变量在构造器的初始化列表中被设置为每个节点的线程数,用户将它作为程序的第一个参数传递。对于每个进入 arena 的线程,该变量递减,只有当值大于 0 时,该变量才会被固定到节点。由于每个numa_arena
都是用thds_per_node
槽初始化的,并且创建竞技场的已经被钉住的主线程占据了其中一个槽,所以首先加入竞技场的thds_per_node - 1
线程将被钉住到节点,并处理由该竞技场正在执行的parallel_for
生成的任务。
注意
我们的 PinningObserver 类的实现并不完全正确。一个线程可能离开竞技场并重新进入同一个竞技场,被钉住两次,但数量会减少thds_per_node
。一个更正确的实现是检查进入竞技场的线程是否是一个还没有被固定到这个竞技场的新线程。为了避免这个例子变得复杂,我们把这个修正留给读者作为练习。
我们现在可以在 yuca 和 aloe 上评估这种 NUMA 优化版 triad 算法的带宽(以每秒兆字节为单位)。为了与图 20-5 中的基线实现进行比较,我们将向量大小设置为 109double,并设置每个 NUMA 节点的线程数量,这样我们最终总共有 32 个线程。例如,在 yuca 中,我们将可执行文件称为:
baseline: ./fig_20_05 32 1000000000
NUMA conscious: ./fig_20_10 8 1000000000
图 20-13 的表格中显示的结果是十次运行的平均值,其中 yuca 和 aloe 有一个用户专门使用该平台进行实验。
图 20-13
由于 NUMA 意识的实施而加速
这在 yuca 上快了 74%,在 aloe 上快了 54%!您会忽略我们通过一些额外的实现工作从 NUMA 架构中挤出的额外性能吗?
为了进一步研究这种改进,我们可以利用能够读出硬件性能计数器的likwid-perfctr
应用程序。通过调用likwid-perctr -a
,我们可以得到一个事件组列表,只需使用组名就可以指定这些事件组。在 aloe 中,likwid
提供了一个NUMA
组,它收集关于本地和远程内存访问的信息。要在我们的基线和 NUMA 感知实现上测量该组中的事件,我们可以调用以下两个命令:
likwid-perfctr -g NUMA ./fig_20_05 32 1000000000
likwid-perfctr -g NUMA ./fig_20_10 16 1000000000
这将报告关于所有核心上的一些性能计数器的值的大量信息。被统计的事件包括
OFFCORE_RESPONSE_0_LOCAL_DRAM
OFFCORE_RESPONSE_1_REMOTE_DRAM
它为我们提供了本地内存和远程内存中被访问数据量的大致信息(因为是基于事件的采样)。对于基线 triad 实现,本地数据与远程数据的比率仅为 3.25,但在 NUMA 优化的 triad-numa 版本中,该比率高达 25.5。这证实了,对于这个内存受限的应用程序,我们利用 NUMA 局部性的努力在本地访问数量和执行带宽方面都取得了回报。
更高级的替代方案
对于常规的三元组代码,我们实现的简单解决方案是可以的,但是 TBB 的偷工减料调度器仅限于独立地平衡每个 NUMA 节点上的负载。在 yuca 上,将有四个parallel_for
算法在运行,每个算法运行在一个 NUMA 节点上,该节点有八个线程,由八个物理内核提供服务。这种简单方法的缺点是,四个竞技场配置了八个插槽,这对于执行的稳态部分来说是没问题的,但是如果 NUMA 节点之间的负载没有完全平衡,就会限制 TBB 的灵活性。
例如,如果其中一个parallel_for
算法首先结束,那么八个线程就变成空闲。他们回到全局线程池,但不能加入其他三个繁忙的舞台,因为所有的位置都已被填满。一个简单的解决方案是增加竞技场的插槽数量,同时保持固定线程的数量为thds_per_node
。在这种情况下,如果一个parallel_for
首先完成,那么返回全局池的八个线程可以在其他三个竞技场的空闲槽中重新分配。请注意,这些线程仍然被固定到原始节点,尽管它们现在将在不同节点的不同领域中工作,因此内存访问将是远程的。
当进入扩展竞技场的线程占用其空闲槽时,我们可以将它们固定到相应的 NUMA 节点(即使它们之前被固定到不同的 NUMA 节点)。现在这些帮助线程也将访问本地内存。但是,该节点可能会超额预订,这通常会影响性能(否则,您应该从一开始就超额预订每个 NUMA 节点)。对于每个特定的应用程序和体系结构,都应该进行彻底的实验,以决定是将线程迁移到 NUMA 节点有利,还是从原始节点远程访问数据有利。对于简单和规则的三元组算法,这些讨论的方法都没有显著提高性能,但是在更复杂和不规则的应用中,它们可能会提高性能。不仅远程访问有开销,而且线程从一个领域到另一个领域的迁移,以及再次锁定线程,都代表了必须通过更好的工作负载平衡来分摊的开销。
我们可以选择进行的另一场战斗与数据分区有关。在我们简单的三元组实现中,我们使用了三个数组的基本块分布,但是我们当然知道对于更不规则的应用程序有更好的数据分布。例如,代替在 NUMA 节点之间预先划分迭代空间,我们可以遵循引导调度方法。在每个 NUMA 节点上领导计算的每个主线程可以在计算开始时获得更大的迭代空间块,并且随着我们接近空间的末端而变小。这里需要注意的是,要保证数据块有足够的粒度,可以在每个 NUMA 节点的内核之间再次重新划分。
一个更复杂的替代方案是以一种分层的方式来概括工作窃取框架。为了允许竞技场之间和每个竞技场内部的偷工减料,可以实现竞技场的层次结构。Chen 和 Guo 为 Cilk 实现了类似的思想(参见“更多信息”部分),他们提出了一个三级工作窃取调度器,对于内存受限的应用程序,与更传统的工作窃取替代方案相比,性能提高了 54%。请注意,与受 CPU 限制的应用程序相比,受内存限制的应用程序将从 NUMA 局部性利用中获益更多。对于后者,内存访问开销通常被 CPU 密集型计算所隐藏。实际上,对于 CPU 受限的应用程序,为了利用 NUMA 局部性而增加调度程序的复杂性会导致额外的开销,最终得不偿失。
摘要
在这一章中,我们探索了一些利用 NUMA 局部性的替代方法,结合了 TBB 和第三方库,有助于控制数据放置和处理器关联性。我们从研究我们想要打败的敌人开始:NUMA 建筑。为此,我们引入了一些盟友库,hwloc
和likwid
。有了它们,我们不仅可以查询 NUMA 拓扑的底层细节,还可以控制数据放置和处理器关联性。我们展示了使用一些hwloc
函数来分配每个节点的内存,为每个 NUMA 节点创建一个线程,并将线程固定到节点的内核。有了这个模板,我们重新实现了 triad 算法的基线版本,现在注意 NUMA 局部性。最简单的解决方案是将三个三元组数组分布在块中,在不同的 NUMA 节点中分配和遍历这些块。库hwloc
是分配和固定线程的关键,TBB task_arena
和task_scheduler_observer
类有助于识别进入特定 NUMA 节点的线程。对于像 triad 基准测试这样常规的代码来说,这个初始解决方案已经足够好了,在两个不同的 NUMA 平台上,分别报告了 74%和 54%的性能提升(相对于基线 triad 实现)。对于更不规则和复杂的应用,本章的最后一节概述了更高级的替代方案。
更多信息
以下是我们推荐的一些与本章相关的额外阅读材料:
-
Christoph Lameter,NUMA(非统一内存访问):概述,ACMqueue,第 11 卷,第 7 期,2013 年。
-
Ulrich Drepper,每个程序员都应该知道的内存知识,
www.akkadia.org/drepper/cpumemory.pdf
,2017。 -
、郭敏毅、关海兵,LAWS:面向多插槽多核架构的局部感知工作窃取,国际超级计算大会,ICS,2014。
开放存取本章根据知识共享署名-非商业-非专用 4.0 国际许可协议(http://Creative Commons . org/licenses/by-NC-nd/4.0/)的条款进行许可,该协议允许以任何媒体或格式进行任何非商业使用、共享、分发和复制,只要您适当注明原作者和来源,提供知识共享许可协议的链接,并指出您是否修改了许可材料。根据本许可证,您无权共享从本章或其部分内容派生的改编材料。
本章中的图像或其他第三方材料包含在本章的知识共享许可中,除非在材料的信用额度中另有说明。如果材料不包括在本章的知识共享许可中,并且您的预期使用不被法律法规允许或超出了允许的使用范围,您将需要直接从版权所有者处获得许可。