六、并发的数据结构
在前一章,我们分享了我们有多不喜欢锁。我们不喜欢它们,因为它们会限制规模,降低并行程序的效率。当然,当正确需要时,它们可以是“必要的邪恶”;然而,我们被很好地建议构造我们的算法以最小化对锁的需求。这一章给了我们一些有用的工具。第章第一章–第四章关注可扩展算法。一个共同的特征是它们避免或最小化了锁定。第五章介绍了显式的同步方法,包括在我们需要的时候使用锁。在接下来的两章中,我们提供了通过依赖 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© 显示了当一个工作线程变得空闲,并且在其当前的任务舞台上找不到更多的工作要做时,它返回到全局线程池。在这一点上,工作者可以加入一个需要工作者的不同的任务竞技场,如果一个可用的话,但是在图 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/)的条款进行许可,该协议允许以任何媒体或格式进行任何非商业使用、共享、分发和复制,只要您适当注明原作者和来源,提供知识共享许可协议的链接,并指出您是否修改了许可材料。根据本许可证,您无权共享从本章或其部分内容派生的改编材料。
本章中的图像或其他第三方材料包含在该章的知识共享许可中,除非该材料的信用额度中另有说明。如果材料未包含在本章的知识共享许可中,并且您的预期用途不被法定法规允许或超出了允许的用途,您将需要直接从版权所有者处获得许可。
dequee 的意思是双端队列,这是一种数据结构,不要与 dequeue 混淆,dequeue 是从队列中删除项目的动作。
*
十、使用任务创建自己的算法
我们最喜欢 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/)的条款进行许可,该协议允许以任何媒体或格式进行任何非商业使用、共享、分发和复制,只要您适当注明原作者和来源,提供知识共享许可协议的链接,并指出您是否修改了许可材料。根据本许可证,您无权共享从本章或其部分内容派生的改编材料。
本章中的图像或其他第三方材料包含在该章的知识共享许可中,除非该材料的信用额度中另有说明。如果材料未包含在本章的知识共享许可中,并且您的预期用途不被法定法规允许或超出了允许的用途,您将需要直接从版权所有者处获得许可。
这有点过于简单化了。请参阅本章前面关于软限制和硬限制的边栏,了解更多信息。