上海科大-CS121-并行计算笔记-全-

上海科大 CS121 并行计算笔记(全)

001:课程介绍与概述

在本节课中,我们将学习并行计算课程的基本信息、课程目标、核心概念以及并行计算在现代计算中的重要性。

课程基本信息与形式

大家好,欢迎来到2020年春季学期的CS121并行计算课程。

我是范睿,上海科技大学信息科学与技术学院的副教授。联系我的最佳方式是通过电子邮件。如有紧急事务,也可拨打我的手机号码。

本课程的形式将与以往不同,我们将采用在线翻转课堂的形式。这意味着我会提前几天将讲义和视频上传到课程Blackboard网站。材料上线后,学生应在课前观看视频并学习讲义。

课程本身也将在线进行,每周在常规授课时间(周一和周三下午3点至4点40分)举行。在这些在线课堂上,我将有机会与所有学生互动,你们可以就任何不清楚的问题提问,我们也可以讨论课程的其他方面。

此外,我将建立一个课程Piazza网站,用于额外的问答和其他类型的讨论。

我们将使用名为Zoom的软件进行在线课程。该软件适用于所有操作系统,包括Windows、Mac、Linux以及iPhone或Android等手机。使用Zoom的方法是:首先访问其网站下载对应操作系统的安装程序。安装软件后,应创建一个账户。在每次上课前(即周一和周三下午3点前),登录Zoom并输入会议ID加入我们的课堂会议室。我们每周都将使用同一个会议ID。

选择Zoom而非微信等其他形式的原因是,Zoom允许你们看到我电脑屏幕上的内容,即可以进行屏幕共享。这样我们可以进行更有用的讨论,例如我可以绘制图表或书写公式,这在微信上是无法做到的。

除了讲座,我们还有一位助教,名叫王乐涵。助教也可以回答学生的问题,并且将大约每两周主持一次习题课。习题课旨在解答关于习题集的问题。每次提交习题集后,我们将在下周安排一次习题课。习题课的具体形式和时间待定,我们会另行通知。

课程评估方式

课程评估包含五个部分。

第一部分是习题集,占总成绩的20%。我们大约每两周会布置一次习题集,总共大约有六次。

第二部分是实验,旨在让大家练习使用本课程中将讨论的各种编程语言,特别是OpenMP和CUDA语言。我会给出不同的问题,要求你们使用OpenMP和CUDA进行编码。

第三部分是项目。实际上有两个项目。第一个是阅读项目,同样占总成绩的20%。你们需要两人一组完成。这个项目旨在让大家接触并行计算领域的研究文献。我会提供一个关于并行计算不同研究前沿的有趣论文阅读清单。你和你的搭档需要从中选择一篇感兴趣的论文,并撰写一份报告。此外,还需要制作一个约20分钟的视频来总结论文的主要内容。阅读清单将在几周后公布,你们需要在第8周(大约4月24日)前告知我你们选择的论文。最终的报告和视频需要在课程结束时(第16周,6月19日)提交。

第二个是编程项目,也占总成绩的20%,同样可以两人一组完成。你们可以与阅读项目是同一组。这个项目的不同之处在于,你们需要找到一个自己感兴趣的问题,并编写一个高效的并行程序来解决它。我会提供一些关于可以研究哪些问题的建议。你们需要在第10周(5月8日)前决定要解决的问题,我们可以进行简短讨论以确认问题的适当性和可行性。对于这个项目,你们需要编写解决问题的程序,并撰写一份关于程序工作原理、性能等方面的报告。同样,也需要制作一个关于项目的短视频。这需要在课程完全结束后(期末考试之后,7月3日前)提交。

最后一部分是期末考试。我尚未决定考试形式,但可能会采用某种在线形式。我设想我们会设定一个考试日期,在那天将试卷上传到Blackboard网站,然后你们可以下载试卷,在大约两小时内完成,并通过拍照的方式提交答案。这将是一场部分开卷考试,允许携带几页自己整理的笔记,但不允许使用任何其他资源,例如上网搜索答案。关于考试的具体形式,我们可以在课程进行中再讨论。

什么是并行计算及其重要性

现在让我们进入本课程的主要内容。首先,什么是并行计算?我们为什么要关心并行计算?

并行计算基本上是研究如何利用多台计算机来解决问题。在算法课上,算法通常以线性形式呈现,想象只有一个处理器在执行你的程序。然而,如果你有一个非常大的程序,一个处理器执行会花费很长时间。在这种情况下,我们希望有多台计算机一起解决这个问题,这就是并行计算。

至于为什么要进行并行计算,有很多原因。

  1. 加速计算:如果你有一个大问题,使用多台计算机可以更快地解决它。理想情况下,如果使用K个处理器,可以将问题解决速度提高K倍。
  2. 解决更大规模的问题:多台计算机集合起来拥有更多内存,因此可以解决规模更大的问题。例如,在进行某种模拟时,如果只有一台计算机,其内存有限,只能进行小规模模拟;如果有多台计算机,总内存量更大,就可以进行更大规模的模拟,或者以更高的精度进行相同规模的模拟。
  3. 容错性:使用并行计算可能更具容错性。如果只使用一个处理器,该处理器故障则程序无法完成;但如果使用多个处理器,即使其中一个故障,其余计算仍可继续。然而,这是一把双刃剑:使用更多计算机提高了容错性,但也增加了至少一台计算机在计算期间发生故障的概率。
  4. 现代计算机系统的本质:如今遇到的所有计算机系统本质上都是并行的。无论是台式机、笔记本电脑中的多核处理器,还是手机中的多核处理器,甚至是游戏图形处理单元(GPU)中的数千个小处理器,以及云计算资源中的数千个处理器,都是并行系统。要高效利用这些现代计算机架构,就需要理解并行计算。
  5. 关键应用的基础:并行计算对于当今许多关键应用至关重要。例如,机器学习在过去几年取得成功的主要原因之一就是能够利用并行计算来大幅加速计算。数据挖掘、天气预报、蛋白质折叠模拟、密码分析、金融和社会行为模拟等,都严重依赖于大规模并行计算。此外,模拟人脑等前沿研究也是并行计算的重要应用方向。

课程目标

本课程的目标是让大家理解并行计算的基本技术和概念,包括算法视角和硬件视角。我将讨论不同的计算硬件模型、不同类型的并行计算机如何构建,以及这些不同的硬件模型如何与你为其设计的软件交互。

事实上,对于并行计算,软件类型与运行软件的硬件类型之间需要非常紧密的协同,这种交互也是我们将要讨论的内容。

我们还将讨论并行性的能力与局限。如果正确使用并行计算,可以获得很多好处,但并行计算并非解决所有问题的灵丹妙药,存在某些我们需要了解的局限性和挑战。

最后,我们将讨论许多高效的并行算法。这些算法通常是编写更大的现实世界并行应用程序时需要用到的构建模块。

并行硬件概述

在并行计算中,硬件和软件需要以某种方式相互匹配。在顺序计算中编写顺序程序时,你不太需要考虑处理器的设计,因为顺序计算机基本上只有一种设计。但在并行计算中,实际上有许多不同的并行硬件设计。因此,在编写并行软件时,需要非常清楚计划运行的硬件类型,并需要让软件能够利用硬件的功能。

一个并行系统基本上由许多处理器组成,每个处理器可以进行独立计算。但由于我们希望结合所有这些处理器来解决一个整体问题,因此处理器之间需要通过互连网络进行通信。

与顺序计算的冯·诺依曼模型不同,并行计算有许多不同的架构。例如,图中展示了一种结合了分布式和共享内存的特定架构。这里有16个核心(图中红色部分),计算在这里进行。我们将这16个核心分成四个块,每块四个核心。每个块内的核心可以通过共享内存进行通信。然而,如果来自两个不同块的核心需要通信,则必须通过代表通信网络的黑色区域。因此,这种架构中有两种类型的通信:共享内存和网络。你可以用许多不同的方式组合共享内存和网络,这些不同的组合导致了不同类型的并行硬件设计。

此外,核心或处理器本身也可以有不同的设计,例如多核处理器、众核处理器、FPGA或其他类型的设计。

不同的并行计算机在互连网络设计上也有所不同。有许多可能的互连架构,例如总线架构、环形架构、星形架构、网格架构、树形架构甚至全连接架构。计算机的通信架构对其性能以及构建此类计算机的复杂性有重大影响。

构建并行计算机时,它不是一个统一的系统,而是一个分层系统,我们在多个不同级别上拥有并行性。整体的并行性是在不同级别上所有并行性的聚合。例如,图中展示了一台超级计算机的设计:从单个芯片开始,芯片内部在指令级或核心级具有并行性;然后将多个芯片组合成一个处理器,放入一张卡中;再将多张卡组合成一个节点;将多个节点组合成一个机架;最后将多个机架组合成一台大型超级计算机。

并行软件概述

接下来,让我们谈谈并行软件的某些方面。我们考虑如何设计一个程序来并行解决问题。

为了并行解决一个大问题,我们首先需要将这个大问题分解成小的子问题(或任务)。然后,我们将分配不同的处理器来解决每个子问题。这些子问题应该在一定程度上彼此独立,这样我们就可以同时处理多个问题。如果所有问题都相互依赖,例如有三个问题,但需要先解决第一个才能解决第二个,再解决第二个才能解决第三个,那么我们就无法实现并行性,因为只能逐个解决问题。另一方面,如果问题是独立的,那么我们可以分配多个处理器同时解决所有这些问题。

第一步是将问题分解为这些子问题或任务。

接下来,我们需要利用操作系统及其调度器来决定如何执行这些任务。首先,我们需要将任务分配给处理器。例如,在右侧的图中,展示了一个示例问题,由许多用圆圈表示的任务组成。此外,这些任务之间存在依赖关系。例如,需要先解决顶部的红色任务,才能解决其下方的任何任务。两个任务之间的箭头表示箭尾的任务需要在箭头任务之前解决。在使用操作系统或调度器分配这些任务时,我们需要以尊重任务间依赖关系的方式进行。

并行软件还需要注意,我们分解问题的方式应该与运行的硬件相匹配。例如,任务分解中的并发量应与硬件中的并发量相似。如果我们只有20个任务,就不希望有1000个处理器,因为那样大多数处理器将闲置。同样,如果有很多任务,也不希望只有一两个处理器。我们需要匹配软件和硬件中的并发量。

此外,硬件必须能够支持软件所需的通信模式。不同的程序有不同的通信模式。例如,有些程序可能很简单,可以将任务排列在一条线上,每个任务只需要与线上的两个邻居任务通信。在这种情况下,程序可以在相当简单的并行硬件上运行。但如果有一个更复杂的程序,所有任务都需要与所有其他任务通信,那么就需要确保通信网络能够支持这种大量的通信。

由于不同的程序需要不同类型的硬件才能高效运行,这意味着没有一种单一的并行硬件类型对所有并行软件都是高效的。换句话说,并行程序不具备可移植性。如果你将一个特定的并行程序运行在不同的并行机器上,你会得到非常不同的性能,因为有些机器可能支持非常高效的通信,而另一些则不支持。

这种并行软件缺乏可移植性是一个大问题。顺序计算的一个伟大之处在于顺序软件通常是可移植的,你可以将相同的程序运行在Intel、AMD或ARM处理器上,基本上会得到类似的行为。但这对并行计算来说并不成立。

为了克服缺乏可移植性的问题,人们尝试为并行计算构建不同的概念性抽象模型,以建立一个足够通用的模型,使并行软件更具可移植性。其中一个这样的模型称为PRAM模型。PRAM模型试图为并行计算建立一个抽象模型。它在某些方面是成功的,例如,PRAM模型非常有助于我们理解问题中的并行性在哪里。然而,它在其他方面不太成功,因为即使我们知道并行性在哪里,PRAM模型并没有说明如何成功执行那种程度的并行性,原因是PRAM模型没有考虑通信成本。通信实际上非常重要,而PRAM模型没有任何方法来考虑这种通信,因此,你在PRAM模型中得到的成本通常与现实生活中得到的性能不匹配。尽管如此,PRAM模型确实有一些用途,我们将在课程后期更多地研究这个模型。

并行计算的挑战

现在让我们谈谈并行计算中的一些挑战。并行计算的核心是让许多实体协同工作来解决一个大问题,就像建造金字塔一样。然而,即使有成千上万的人,也不意味着他们就能建造金字塔,因为人们需要作为一个团队工作,而当你想让许多人作为一个团队工作时,就会面临许多挑战。

  1. 通信:为了让许多处理器协同解决一个大问题,处理器需要相互通信,共享数据,告诉彼此他们正在处理问题的哪些部分以及哪些部分已经完成。通信之所以困难,首先是因为处理器的计算速度远快于通信速度。处理器每秒可以进行许多计算,但无法将所有计算的结果传递给其他处理器,处理器通信存在带宽限制。此外,当拥有多个处理器时,通信量实际上增长得非常快,随着处理器数量的增加,通信瓶颈会变得更加严重。例如,如果有n个处理器,并且所有处理器都需要相互通信,那么将需要大约n²个通信链路。通信量相对于处理器数量呈二次方增长,这是并行计算的主要瓶颈之一。
  2. 同步:当我们尝试同时解决多个任务时,任务实际上可能会相互干扰。例如,在道路上,车辆可以水平或垂直并行通过,但它们不能同时水平和垂直通过。不同方向行驶的车辆需要相互同步、协调,这就是为什么我们有交通信号灯。如果没有这种协调,就会出现所有车辆都被堵住的情况。我们需要这种同步,但同步也会带来成本。
  3. 调度:我们需要将任务分配给处理器,并决定处理器何时运行不同的任务。有许多依赖关系需要强制执行,调度器实际上需要运行一个相当复杂的算法来确保强制执行所有这些依赖关系。此外,调度器希望确保它使用了所有可用的硬件资源,如果有很多任务和很多处理器,它希望确保所有处理器都在运行某些任务,而不是闲置。为了在任务和处理器之间找到良好的分配,我们需要考虑数据局部性等问题,或者如果我们有不同类型的异构处理器,我们需要考虑将哪些任务分配给哪些类型的处理器。一旦我们将任务分配给处理器,我们还应该考虑如何将每个处理器上的资源分配给任务。
  4. 结构化与非结构化问题:结构化问题类似于稠密矩阵计算,例如矩阵乘法,每个输出条目都使用相同且非常规则的过程产生。另一方面,如图计算等,图形比矩阵混乱得多,节点可能连接到许多其他节点或仅连接到少数节点,现实生活中处理的图形结构很少。因此,当你有结构时,解决问题实际上更容易。如果你知道需要访问哪些数据以及对这些数据进行何种计算,就可以为这些计算构建定制硬件;而如果你不知道计算的结构,则必须构建一个通用的硬件,效率必然较低。
  5. 固有的顺序性问题:即使我们能解决通信、同步和结构的所有问题,也存在一些根本不可并行化的问题,或者至少目前看来不可并行化。我们目前没有解决这些问题的高效并行算法,尽管我们也不能证明不存在高效的并行算法。例如,单源最短路径问题(Dijkstra算法)似乎是一个固有的顺序过程,目前我们没有运行Dijkstra算法的高效并行方法。然而,有些算法看起来是顺序的,但实际上并非如此,你可以想出更聪明的算法来并行解决这些问题,例如计算斐波那契数列。因此,并行计算的一个重要部分是提出具有足够并行性的算法来解决问题,但有时我们会被困住,有些问题目前还没有人能够提出高效的并行解决方案。
  6. 人为因素:设计并行计算的最后一个挑战是人为因素。我们必须设计这些算法和硬件,而对我们来说,提出并行算法之所以困难,是因为在并行算法中,许多事情同时发生,这就是并行性的定义。如果你想为此设计一个高效且正确的算法,作为设计者,你必须跟踪所有这些同时发生的不同事情,这很难在脑海中跟踪这么多事情。人脑并非为此而生,人脑习惯于一次跟踪一件事,如果你试图思考10件事同时发生,大脑会不堪重负。

课程大纲

现在,我将给出本课程将涵盖内容的概要。

  1. 并行架构:我们将首先讨论不同类型的并行架构,包括共享内存架构、分布式内存架构和众核架构,并涵盖这些架构的不同方面。
  2. 编程语言:之后,我将介绍编程并行硬件的不同方法。由于有不同类型的硬件,也有不同类型的语言来利用这些不同的硬件架构。我们将涵盖的语言包括OpenMP、MPI、CUDA和MapReduce。
  3. 高效并行算法设计:在介绍语言之后,我将讨论如何设计高效的并行算法。有几个步骤,例如,你需要分解问题,确保分解具有某些属性(如不同任务之间是平衡的),然后还需要能够将这些任务调度到硬件上。
  4. 并行算法构建模块:正如前面提到的,有一些并行算法是其他类型并行计算的基础构建模块。我们将尝试涵盖大多数基础的算法构建模块,包括稠密和稀疏矩阵算法、并行排序或搜索,以及一些并行图算法。
  5. PRAM模型:我将提到PRAM模型,这是一个旨在帮助创建可移植并行算法的理论模型。尽管这个模型存在问题,但使用PRAM设计的算法,其核心思想可以尝试应用于不同的现实世界场景。因此,我们将学习一些PRAM算法,看看如何将这些基本思想应用于更现实的场景。

并行计算的历史与现状

接下来,我想谈谈并行计算的历史,我们是如何走到今天的,以及并行计算的现状。

并行计算基本上始于20世纪60年代,随着计算的曙光出现。到1976年,出现了第一台超级计算机Cray-1,速度约为160兆次浮点运算每秒。在80年代,超级计算真正蓬勃发展,出现了许多成功的超级计算机公司。这些在80年代建造的计算机的一个特点是它们基本上是定制的,每台计算机都有自己的架构和处理器,设计这些处理器非常昂贵。

进入90年代,一种新型的超级计算机架构开始被采用,称为大规模并行处理器架构集群架构。MPP和集群都使用商用现货处理器,但MPP使用自己的定制互连,而集群不仅使用现货处理器,还使用现货互连(如以太网)和Linux操作系统。如今,大多数数据中心都是集群计算机,而大多数超级计算机是MPP,拥有自己的定制互连。

目前最快的超级计算机是IBM的Summit,这是一台大规模并行处理器,运行速度约为150千万亿次浮点运算每秒,这比典型的家用工作站快约一百万倍。

除了这些自80年代以来持续建造的超级计算机,并行计算的进展实际上在90年代到21世纪中期曾一度停滞。原因是在那些年里研究并行计算并不划算,而这又是因为摩尔定律。大约50年前,英特尔联合创始人戈登·摩尔预测,我们可以放在处理器上的晶体管数量每18个月翻一番。值得注意的是,这一预测在过去50年里基本成立。但重要的是,摩尔预测的是晶体管数量每18个月翻一番,他并没有预测计算机速度每18个月翻一番。然而,在过去直到2005年左右,每当晶体管数量翻倍时,处理器的速度也会翻倍。这意味着即使在单个处理器上,我们也可以每18个月将晶体管数量翻一番,这也会使该处理器的处理性能每18个月翻一番。这阻碍了并行计算机的发展,因为根本没有必要设计并行计算机,因为设计每台并行计算机都需要很长时间,而当你完成设计时,单个处理器的速度已经大幅提高,就不再需要这台并行计算机了。

这种现象一直持续到2005年左右。一旦到了2005年,发生了一些事情,使得并行计算变得更加重要。从图中可以看到,2005年之前,每当晶体管数量翻倍,我们也可以提高处理器的时钟频率,从而提高处理器性能。然而,一旦到了2005年,频率无法再提高了,因为晶体管变得非常小,当把它们放在单个处理器上时,会产生大量的热量,消耗大量功率,并且泄漏大量功率,由于这些原因,无法再提高时钟频率。那么,如果频率被卡住,我们如何提高性能呢?基本上在同一时间,处理器上的核心数量开始增加。核心是进行计算的基本单元,一个处理器由多个核心组成。一旦到了2005年,我们无法再提高每个核心的性能,因为无法再提高核心的频率。因此,为了获得更好的性能,我们需要增加核心的数量。所以现在每个处理器有四个或八个核心,有些处理器甚至有64个核心。这就是现在获得更好性能的方式,不是通过提高频率,而是通过增加并行性来增加核心数量。这就是为什么并行计算在2005年之后变得更加重要。在2005年之前,你不需要并行计算来获得更好的性能,但自2005年以来,这基本上是我们获得更好性能的唯一途径。

如今我们使用的芯片通常是多核计算机。我们不是让单个核心更快,而是在同一个处理器上放置多个核心,这样可以成倍提高计算能力。开发多核软件的难度实际上比开发硬件更大,原因是为了开发并行软件,会遇到前面提到的人为中心的问题,即人类并非天生就能同时思考多件事情。如果开发人员编写了一个多线程程序,这些线程经常会出现错误,无法以正确的方式交互。因此,如今非常需要懂得如何成功使用并行计算的软件开发人员,因为利用并行硬件的唯一途径就是开发高效的并行软件。

目前,我们拥有的并行计算机主要分为四种架构:

  1. 多核架构:具有少量到中等数量的核心。这些核心是完全通用的,可以执行任何类型的计算,并且核心本身速度很快。
  2. 众核架构:拥有更多的核心,可能有数千个核心。但由于数量众多,每个核心比多核简单得多,功能也较弱。典型的例子是NVIDIA的GPU或Intel的Xeon Phi。
  3. FPGA:基本上是定制硬件,但可以动态重新配置。由于是定制硬件,它们针对特定问题,因此对这些特定问题可以快得多。
  4. ASIC:完全定制的硬件,专为非常专业化的问题设计。例如Google的TPU、Apple的Neural Engine或IBM的TrueNorth。

除了速度,我们还希望处理器节能高效,这很重要,因为如今的计算机消耗大量电力。大型数据中心可以消耗数百兆瓦的电力,相当于数万户家庭的用电量。大型超级计算机也可以消耗数十兆瓦的电力。因此,构建更节能的计算机非常重要。

在超级计算机方面,衡量其性能的常用方法是TOP500榜单。这是世界上500台最快超级计算机的列表,每年发布两次。他们通过运行一个名为Linpack的特定基准测试来衡量超级计算机的性能,该基准测试解决一个稠密线性系统问题。根据这个基准,目前最快的超级计算机是Summit,基于IBM Power9处理器和NVIDIA Volta GPU,拥有超过200万个核心,性能约为150千万亿次浮点运算每秒,功耗约为11兆瓦。

需要注意的是,Linpack基准测试解决的是使用高斯消元法的稠密线性系统,这种计算计算密集,但内存操作不多。因此,Linpack是衡量处理器速度的好指标,但不能很好地衡量通信网络的性能。许多计算实际上更依赖于通信而非计算。因此,Linpack基准测试可能不能代表在更频繁使用内存系统的现实问题上的性能。

为了衡量计算机在这些新型应用上的性能,我们需要新的基准测试。例如,HPCG基准测试测量稀疏矩阵上的性能,其内存访问模式更不规则。还有Graph 500基准测试,测量图问题(基本上是广度优先搜索)的性能。我们会看到,计算机的性能实际上会因使用的基准测试而有显著差异。

天河二号超级计算机案例研究

最后,我想带大家了解一台特定的超级计算机——天河二号的设计。

天河二号目前是世界上第四快的超级计算机。它基于Intel处理器,拥有32,000个Intel Xeon CPU和48,000个Xeon Phi协处理器,总计约55千万亿次浮点运算的性能。它使用定制的互连网络TH Express-2。总内存为1.4 PB,存储为12 PB。计算机由多个机柜组成,共有125个计算柜、13个通信柜和24个存储柜,总功耗约为18兆瓦,采用封闭式空气冷却系统。

这些组件被组织成16,000个计算节点,每个节点包含两个Intel Xeon CPU和三个Xeon Phi协处理器。每32个节点组成一个框架,每四个框架组成一个机架,总共有125个计算机架。

每个节点拥有64 GB内存,使用PCIe 3.0 x16接口,每个节点性能约为3.4万亿次浮点运算每秒。

互连网络基于一种称为胖树拓扑的结构。有13个机柜用于互连,每个机柜使用胖树拓扑,拥有576个端口,并采用专有的通信协议。

编程这台计算机涉及多个层次:底层是系统环境,包括操作系统、并行文件系统和资源调度器;之上是编程环境,支持MPI、OpenMP等框架,以及调试工具、编译器和性能分析器;最上层是应用环境,运行实际的应用;此外还有容错管理和整体管理环境。

操作系统是Kylin Linux的一个版本,资源管理系统负责任务调度和功耗跟踪。

在这台系统上运行的一个成功应用是大规模浅水模拟,该模拟使用了4000个节点(占计算机总节点的四分之一),模拟了全球水流,涉及超过2000亿个未知数,这是一个令人印象深刻的成就。

教材与结束语

最后,我想提一下本课程可以使用的教材。这些教材不是必需的,因为课程计划涵盖的所有材料都将在讲义和视频中提供,但如果你想要更详细地了解某些主题的参考资料,我可以推荐以下教材。之所以列出多本教材,是因为实际上没有一本教科书能非常深入地涵盖并行计算的所有方面。

  1. 《并行计算导论》(Grama, Gupta, Karypis, Kumar)
  2. 《并行编程导论》(Pacheco)
  3. 《大规模并行处理器编程》(Kirk, Hwu)
  4. 《CUDA by Example》

其中一些书籍可以在线获取。如果你确实无法访问这些书籍,可以来找我,但再次强调,这些书籍对课程并非严格必要,只是供感兴趣的同学参考。

本节课中,我们一起学习了并行计算课程的基本信息、并行计算的核心概念与重要性、并行硬件与软件的概述、面临的挑战、课程大纲、并行计算的历史与现状,并通过天河二号的案例了解了超级计算机的设计。在接下来的课程中,我们将深入探讨这些主题的各个方面。

002:并行计算机架构 🏗️

在本节课中,我们将学习并行计算机架构的不同层面。我们将从单个处理器内部的并行性开始,逐步扩展到多核处理器、共享内存和分布式内存系统,并探讨处理器间的互联网络。

单处理器内部的并行性 🔍

上一讲我们提到了并行计算机中存在多个层次的并行性。本节中,我们来看看即使在单个处理器核心内部,也存在多种并行执行指令的方式。

流水线并行

执行一条指令通常包含多个子步骤,例如:取指、译码、执行、访存、写回。流水线技术将这些步骤分解,并在不同的硬件单元上同时执行不同指令的不同阶段,从而实现隐式并行。

核心概念:一个五级流水线处理器在每个时钟周期可以处理多达五条指令的不同阶段,理想情况下可获得近五倍的加速比。

然而,流水线面临一个主要挑战:分支预测。如果代码中存在条件分支,处理器需要猜测执行路径。如果预测错误(分支误预测),整个流水线需要被清空,导致性能损失。因此,高精度的分支预测对流水线性能至关重要。

超标量并行

超标量架构在流水线的基础上更进一步,它为每个功能单元(如取指、译码单元)配备了多个副本。这样,即使代码本身是顺序的,处理器也能在每个时钟周期发射并执行多条指令。

以下是超标量处理器如何并行执行一段代码的示例。假设我们要将四个内存地址的值相加:

LOAD R1, [1000]
LOAD R2, [1004]
ADD R1, R1, [1008]
ADD R2, R2, [1012]
ADD R3, R1, R2

这段代码中,前两条LOAD指令是相互独立的,可以同时被两个取指单元获取并解码。同样,后续的ADD指令也存在可以并行执行的机会。超标量处理器会动态地发现这些机会。

数据依赖与指令调度

指令能否并行执行,取决于它们之间的数据依赖关系。如果一条指令需要使用前一条指令的结果,则必须顺序执行。

比较以下两段功能相同但顺序不同的代码:

代码A(依赖少,并行度高):

LOAD R1, [1000]
LOAD R2, [1004]  // 与上一条独立
ADD R1, R1, [1008] // 依赖 LOAD R1
ADD R2, R2, [1012] // 依赖 LOAD R2,但与上一条 ADD 独立
ADD R3, R1, R2     // 依赖前两条 ADD

代码B(依赖多,并行度低):

LOAD R1, [1000]
ADD R1, R1, [1004] // 依赖上一条
ADD R1, R1, [1008] // 依赖上一条
ADD R1, R1, [1012] // 依赖上一条

代码A的指令间依赖更少,为超标量并行提供了更多机会。为了充分利用硬件,处理器需要复杂的硬件来动态检测依赖、重排序指令流,并尽可能填满所有功能单元,避免资源闲置。

超长指令字并行

VLIW(超长指令字)是另一种利用隐式并行的方式。与超标量在运行时动态发现并行指令不同,VLIW依赖编译器在编译时静态地将多条可以并行执行的指令打包成一条很长的指令。

  • 优点:编译器可以进行更复杂、更全局的优化来寻找并行性,硬件设计得以简化(无需复杂的动态调度逻辑)。
  • 缺点:编译器无法预知运行时的所有情况(如缓存命中、分支走向),可能错过一些仅在运行时才显现的并行机会,导致性能潜力无法完全发挥。

内存性能与层次结构 🧠

处理器的性能不仅取决于计算速度,还严重依赖于获取数据的速度。内存系统的两个关键指标是延迟带宽

  • 延迟:处理器请求一块数据到收到该数据所需的时间。
  • 带宽:单位时间内可以从内存传输到处理器的数据总量。

它们可以用高速公路来类比:延迟好比车辆从起点到终点所需的时间(或公路的长度),而带宽好比公路的车道数,决定了单位时间内能通过多少车辆。

CPU-内存性能鸿沟

多年来,处理器性能的提升速度远远超过了内存性能的提升,导致两者之间出现巨大鸿沟。一个能执行100 GFLOPs的处理器,如果内存带宽只能支持每秒传输50亿个浮点数(20 GB/s),那么实际性能将被限制在5 GFLOPs,仅为峰值性能的5%。

内存层次结构

为了解决这个鸿沟,现代计算机采用了内存层次结构。从快到慢、从小到大依次包括:寄存器、L1缓存、L2缓存、L3缓存、主内存(RAM)、二级存储(如硬盘)。速度越快的内存,容量通常越小。

缓存是位于处理器芯片上的小型、高速内存,充当处理器和主存之间的缓冲区。其目标是让处理器最常访问的数据驻留在缓存中,从而以低延迟和高带宽进行访问。

局部性原理

缓存要发挥作用,程序必须具有良好的局部性

  1. 时间局部性:如果某个数据被访问,那么它在不久的将来很可能再次被访问。
  2. 空间局部性:如果某个数据被访问,那么其邻近地址的数据很可能很快被访问。

当程序具有良好局部性时,缓存命中率会很高。缓存命中率对平均访问时间有巨大影响:

公式平均访问时间 = 命中率 × 缓存延迟 + (1 - 命中率) × 内存延迟

例如,假设缓存延迟5 ns,内存延迟100 ns:

  • 缓存命中率80%时,平均访问时间 = 0.8*5 + 0.2*100 = 24 ns
  • 缓存命中率90%时,平均访问时间 = 0.9*5 + 0.1*100 = 14 ns

相比之下,若无缓存,每次访问都需100 ns。因此,缓存能显著提升系统有效性能。

多处理器架构 🧩

了解了单处理器内部的并行后,我们现在进入并行层次结构的下一级:将多个处理器组合成更大的系统。

核心、处理器与线程

  • 核心:一个独立的执行单元,包含取指、译码、执行、寄存器状态等部件,可运行一个或多个线程。
  • 线程:一个独立的指令流,由程序计数器、寄存器状态等定义其执行状态。

架构分类

  1. 单核单处理器:传统顺序处理器的基础。
  2. 多处理器:多个独立的处理器芯片通过主板互联,通常通过共享内存进行通信。
  3. 多核处理器:单个芯片上集成多个核心。核心间可以共享最后几级缓存,通信延迟低,能提高缓存利用率,但也可能因竞争缓存导致性能下降。
  4. 同时多线程处理器:在单个核心上复制多份线程状态(寄存器、程序计数器),但共享执行单元和缓存。这使得单个核心能同时运行多个线程,当线程使用不同的功能单元时,可以提高硬件利用率。Intel的超线程技术就是SMT的一种实现。
  5. 多核超线程处理器:现代处理器的常见形态,结合了多核和每个核心上的SMT。

弗林分类法 🏷️

另一种从指令和数据流角度对处理器进行分类的方法是弗林分类法:

  1. SISD:单指令单数据。传统顺序处理器。
  2. SIMD:单指令多数据。同一指令同时作用于多个数据元素。非常适合向量运算、图形处理。现代CPU的向量指令(如AVX)和GPU都是SIMD或类似架构。
    • 优点:硬件设计相对简单,控制单元只需一份。
    • 缺点:遇到条件分支时,不同数据元素可能导致不同执行路径,造成分支发散,需要串行化处理各路径,降低效率。
  3. MISD:多指令单数据。不常见,可认为流水线是某种形式的MISD。
  4. MIMD:多指令多数据。每个处理器独立执行不同的指令流处理不同的数据。这是最通用的并行架构,多核CPU和分布式内存集群都属于MIMD。

现代系统通常是SIMD和MIMD的混合体。例如,一个多核CPU(MIMD)的每个核心都支持SIMD向量指令。

互联网络与内存架构 🌐

多个处理器需要互联以协作。互联网络的拓扑结构对系统性能和可扩展性至关重要。

内存架构类型

  1. 共享内存:所有处理器共享一个统一的逻辑地址空间。任何处理器都能直接访问任何内存地址。

    • 优点:编程模型简单,数据共享直观。
    • 缺点:可扩展性有限,通常只能扩展到数百个处理器。需要复杂的缓存一致性协议。
    • 变体
      • UMA:均匀内存访问。所有处理器访问任何内存位置的时间相同。
      • NUMA:非均匀内存访问。处理器访问不同内存模块的时间不同,取决于物理距离。
  2. 分布式内存:每个处理器拥有自己的本地内存,构成独立的地址空间。处理器不能直接访问其他处理器的内存,必须通过消息传递进行通信。

    • 优点:可扩展性极好,能构建成千上万个处理器的大型系统。
    • 缺点:编程模型复杂,程序员必须显式管理数据的分布和通信。
  3. 混合架构:大规模并行计算机的常见形态。节点内部采用共享内存(多核),节点之间采用分布式内存(消息传递)。

网络拓扑结构

互联网络的性能常用以下指标衡量:

  • 直径:网络中任意两个节点间的最长距离(跳数)。直径越小,最坏情况下的通信延迟越低。
  • 对分带宽:将网络分成大小相近的两部分时,必须切断的链路的总带宽。对分带宽越大,网络并行通信能力越强。
  • 成本:网络中的链路总数。

以下是几种常见拓扑结构的比较:

以下是几种常见拓扑结构的特性总结:

拓扑结构 直径 对分带宽 成本 (链路数) 描述
线性阵列 P-1 1 P-1 处理器排成一条线,简单但性能差。
P/2 2 P 在线性阵列首尾增加一条连接。
2D 网格 2(√P - 1) √P ~2P 处理器排列成二维网格,可扩展性较好。
2D 环面 √P 2√P ~2P 在2D网格的每行每列首尾增加连接。
3D 网格/环面 3(∛P - 1) ∛P² ~3P 扩展到三维,进一步降低直径。
二叉树 2 log₂ P 1 P-1 直径小,但对分带宽是瓶颈。
胖树 2 log₂ P P/2 ~(P log₂ P)/2 树中越靠近根节点的链路带宽越大,解决了对分带宽问题。
超立方体 log₂ P P/2 (P log₂ P)/2 由递归方式构建,直径和对分带宽性能都极佳。

超立方体是一种性能优异的拓扑。一个k维超立方体有2^k个节点,每个节点有k个邻居(其二进制标签仅一位不同)。其直径仅为k(即log₂ P),对分带宽为P/2。虽然难以可视化高维超立方体,但它被用于一些经典超级计算机(如Connection Machine CM-2)的互联网络。

总结 📚

本节课我们一起学习了并行计算机架构的多个核心方面:

  1. 单处理器内部的并行:通过流水线超标量VLIW等技术,挖掘指令级并行。性能受限于数据依赖分支预测
  2. 内存系统延迟带宽是关键指标。CPU-内存性能鸿沟通过内存层次结构(尤其是缓存)来缓解,其有效性依赖于程序的时间局部性空间局部性
  3. 多处理器架构:认识了多核超线程等组织多个计算单元的方式。
  4. 弗林分类法:从指令/数据流角度将架构分为SISD、SIMD、MISD、MIMD。现代系统多为混合型。
  5. 互联网络与内存模型:区分了共享内存分布式内存模型及其优缺点。探讨了多种网络拓扑结构(线性、网格、环面、树、超立方体等)及其对直径对分带宽成本的影响。

理解这些底层架构概念,是编写高效并行程序、为其选择合适平台的基础。下一讲,我们将深入探讨共享内存和分布式内存架构中的通信机制。

003:通信问题与协议

在本节课中,我们将要学习并行系统中,特别是共享内存和分布式内存系统中的通信问题。我们将探讨缓存一致性问题、不同的缓存一致性协议、通信成本模型、路由算法以及进程到处理器的映射策略。

缓存一致性问题 🧠

上一节我们介绍了并行计算的基本概念,本节中我们来看看通信中的核心挑战之一:缓存一致性问题。

在共享内存系统中,每个变量在逻辑上应该只有一个副本。然而,当多个进程需要访问同一个变量时,每个进程都可以在自己的缓存中存储该变量的一个副本。这样,当它们访问该变量时,可以直接从自己的缓存中读取,这比从主内存访问要快得多,从而提高了内存系统的效率。

但是,问题在于,如果其中一个处理器修改了该变量,那么其他处理器必须知道该变量值的变化。由于该变量存储在多个缓存中,每次某个处理器修改该变量时,缓存必须通过某种过程相互保持一致或一致。这就是缓存一致性问题:如何使多个缓存相互保持一致?

缓存一致性协议 🔄

以下是两种主要的缓存一致性协议类型:

  • 失效协议:当一个进程修改变量时,该变量的所有其他缓存副本都被声明为无效。例如,如果处理器P0将变量x的值从1修改为3,那么处理器P1和主内存中的副本将被标记为无效。如果另一个处理器(如P1)想要访问这个无效变量,那么拥有有效副本的处理器(P0)首先需要将值写回内存,然后其他处理器才能从内存中读取这个新值。
  • 更新协议:当一个进程修改变量时,它会将变量的新值写入所有其他缓存和内存。例如,如果P0将x的值改为3,它会将这个新值写入P1的缓存和主内存。

失效协议和更新协议在通信与速度之间做出了权衡。如果其他进程不频繁读取该变量,更新协议会浪费大量通信,因为即使其他处理器不读取,它也会更新所有处理器。此时,失效协议更优。反之,如果其他进程频繁读取该变量,失效协议会更慢,因为读取进程需要等待写入进程将值写回内存,然后再从内存读取。此时,更新协议更优。

在实践中,由于两种协议都需要执行一些通信操作,而带宽通常是有限的,为了节省带宽,通常选择使用失效协议。

MSI协议详解 🧩

上一节我们介绍了两种基本的缓存一致性协议,本节中我们来看看一个具体的、基于失效的协议:MSI协议。它是一个较简单的协议,但也是更复杂、高性能协议(如MESI和MOESI)的基础。

MSI协议之所以得名,是因为每个变量可以处于三种不同的状态:M(已修改)、S(共享)或I(无效)。每当一个进程执行一个操作(例如读或写)时,都会导致变量的状态改变,并引发一致性消息发送到主内存和其他处理器。

以下是这三种状态的含义:

  • 共享状态:如果一个变量处于共享状态,那么它同时存在于多个缓存中。该变量在所有缓存中的值都相同,并且自每个处理器上次从内存读取该变量以来,其值没有改变。因此,所有副本都是最新的,处理器可以直接从自己的缓存中读取该值。
  • 已修改状态:也称为脏状态。这意味着某个处理器(例如P)已经修改了其缓存中该变量x的值。因为P修改了x,它必须告诉所有其他处理器,它们当前缓存的x值现在已经过时,因此无效。P自己拥有x的最新值,因此可以在自己的缓存中读取和写入x。最终,当P不再需要x时(例如,当x被逐出缓存时),由于P是唯一拥有x最新值的处理器,它必须将x的最新值写回或刷新到主内存,以便其他处理器可以看到新值。
  • 无效状态:这意味着某个其他处理器已经更改了x的值,因此该处理器的x值已过时。如果这个处理器想要读取x的值,它必须去主内存读取。主内存中的x值也可能已过时,因此当这个进程想要读取x时,会导致当前拥有x值的处理器将当前值写回内存,然后这个处理器才能从内存中读取这个新值。

状态机与操作示例

该协议可以用状态机来表示。状态机应用于每个变量,状态指的是处理器对该变量的状态。状态转换由处理器的读写请求触发,并伴随着在总线上发送的一致性消息。

以下是几个操作示例:

  1. 变量当前为共享状态,某个处理器要写入新值:写入处理器会生成一个“处理器写”消息,这会导致一个“总线读独占”消息在总线上发送。写入处理器进入已修改状态,而所有其他处理器接收到总线读独占消息后,进入无效状态。
  2. 变量当前为脏状态(被某个处理器P修改),另一个处理器Q要读取该值:Q处于无效状态。为了读取变量,Q生成一个“处理器读”消息,导致一个“总线读”消息在总线上发送。处于已修改状态的处理器P接收到此消息后,将其当前修改的值刷新到内存,然后P进入共享状态。同时,Q也从内存读取到最新值,并进入共享状态。
  3. 一个处理器处于已修改状态,它要读取或写入该变量:由于该处理器已经拥有该变量的最新副本,它可以直接读取自己的缓存副本或写入新值,无需向总线发送任何消息。这种情况下,缓存带来了很大的性能提升。

协议实现:监听缓存与目录缓存 🏗️

上一节我们详细了解了MSI协议的状态转换,本节中我们来看看如何在不同的硬件系统中实现这个协议。

  • 监听缓存系统:这是实现MSI的最简单系统。它通常设置在总线或环上,所有处理器都连接到这个总线或环。任何在总线上发送的消息都会被所有其他处理器“听到”。协议的正确性依赖于消息在总线上广播且所有进程都能听到所有消息。最佳情况是只有一个进程试图修改变量,且没有其他进程试图访问该变量。此时,该进程处于已修改状态,修改变量时无需向总线发送任何数据,因此性能很好。然而,如果有多个进程都试图并发修改变量,每次修改都会导致刷新和总线消息,互联总线会成为性能瓶颈。
  • 目录缓存系统:为了解决通信瓶颈,人们尝试了目录缓存系统。其核心思想是,当修改变量时,唯一关心并需要知道这一变化的,是那些拥有该变量共享或脏副本的处理器。目录数据结构用于跟踪哪些处理器拥有每个变量的共享或脏副本。对于每个内存地址(变量),有一组存在位,每个处理器对应一位。如果位被设置,表示该处理器拥有该变量的共享或脏副本。当修改变量时,只需通知那些存在位被设置的处理器。目录本身存储在内存中,这可能导致内存访问成为瓶颈,且目录大小与内存块数乘以处理器数成正比,可能非常庞大。
  • 分布式目录:为了提高目录的性能,可以使用分布式目录。将目录分割成多个部分,分配给不同的处理器。每个处理器负责一部分内存及其对应的存在位。当处理器需要读取目录信息时,它首先确定哪个处理器拥有该变量,然后只访问那个处理器的目录部分。这样就避免了集中式的瓶颈。

伪共享问题与通信成本测量 ⚠️

在缓存中,从主内存传输数据到缓存时,不是以单个字节为单位,而是以较大的内存块(称为缓存行或块)为单位。典型的L1缓存行大小可能是64到128位。在一个缓存行内,可能有多个内存字。

伪共享问题发生在两个处理器访问完全不同的内存位置,但这些位置恰好位于同一个缓存行中。例如,处理器1读取内存位置3,处理器2读取内存位置5。当处理器1修改位置3时,它需要更新整个缓存行。这会导致处理器2的缓存行也被无效化,即使处理器2访问的是不同的内存地址。这会产生大量内存一致性流量,损害性能。解决方法之一是尝试通过数据填充或重新排列数据,使被不同处理器频繁访问的变量不在同一个缓存行中。

接下来,我们看看如何测量通信成本。首先考虑消息传递环境下的成本。

假设我们要将大小为M的消息从一个节点传输到另一个节点,成本包括以下几个部分:

  • 启动时间:用 ( T_s ) 表示,包括准备消息头、添加纠错码、运行路由协议等。无论消息传输多远,只需执行一次。
  • 每跳时间:用 ( T_h ) 表示,消息可能经过多个中间路由器,每个中间路由器确定下一跳需要时间。
  • 传输时间:用 ( T_w ) 表示,与链路上的带宽有关。如果带宽为R(每秒可传输R个字),则每字的传输时间为 ( 1/R )。

路由算法:存储转发、分组与直通 🛣️

以下是几种不同的路由协议及其成本:

  • 存储转发路由:如果消息需要经过多个中间处理器,每个中间节点都会等待接收到整个消息后,才将其转发到下一跳。总时间约为 ( T_s + L \times (M \times T_w) ),其中L是跳数。每跳时间 ( T_h ) 通常较小,可以忽略。
  • 分组路由:与存储转发不同,分组路由不会等到接收整个消息才转发。它将消息分解成许多小分组,一旦收到分组就立即转发。这实际上是通过流水线发送消息。分组路由更快,允许不同分组走不同路由以避免拥塞,并且可以对每个分组进行纠错。缺点是每个分组都需要开销(如路由信息和纠错码)。总时间约为 ( T_s + L \times T_h + M \times T_w )。当启动时间和每跳时间较小时,存储转发比分组路由慢L倍。
  • 直通路由/直通交换:这是针对并行计算机优化的分组路由。所有分组走相同路径,因此不需要为每个分组存储大量路由信息。它使用称为“流控制数字”的小数据单元进行高速传输。第一个flit(头flit)由交换机和路由器分配路径,后续所有flit都遵循相同的路径。这样可以简化交换和路由,并对整个消息进行纠错。还可以通过设置多个优先级的通信通道来支持高优先级消息。其他名称包括虚拟直通和虫孔路由。

整体成本模型与拥塞分析 📊

综合以上内容,我们可以得到一个整体通信成本模型。总通信时间取决于传输的数据量M和经过的跳数L。然而,我们通常忽略L参数,原因如下:

  1. 程序员无法控制L(跳数),这取决于进程到处理器的映射。
  2. 许多路由系统使用随机化两步路由来最小化拥塞,这进一步使程序员无法控制跳数。
  3. 每跳时间 ( T_h ) 通常远小于启动时间或发送消息的总时间,尤其是对于大消息。

因此,通信时间可以简化为 ( T_s + M \times T_w )。这个表达式假设网络无拥塞。如果存在拥塞,假设某条链路上有C条大小为M的消息,则发送所有消息的时间约为 ( T_s + C \times M \times T_w )。

拥塞示例:假设有一个P个处理器的网格(大小为 ( \sqrt{P} \times \sqrt{P} )),所有处理器随机相互通信。如果对网络进行切割,一半处理器试图与另一半通信,则可能有O(P)次通信需要穿过 ( O(\sqrt{P}) ) 条链路。因此,某条链路的拥塞至少为 ( \sqrt{P} )。通信时间变为 ( T_s + \sqrt{P} \times M \times T_w ),拥塞显著增加了通信时间。

共享内存通信成本与路由算法属性 🧵

现在,我们转向共享内存模型中的通信成本。在这个设置中,通信时间更难以预测,原因包括:

  1. 内存布局由操作系统决定,程序员无法控制,因此无法控制本地与远程内存访问的比例。
  2. 缓存命中率取决于与谁共享缓存,这又取决于调度器和进程分配,具有不可预测性。
  3. 一致性流量、代码局部性、伪共享等问题都是应用相关的,并且可能在运行时变化。

尽管存在这种不可预测性,总通信时间仍然包含启动时间和数据传输时间两部分,即 ( T_s + M \times T_w )。然而,在共享内存系统中,( T_s ) 和 ( T_w ) 的值通常比分布式内存架构中小得多,因为共享内存系统通常更小,数据传输距离更短。

接下来,我们看看在这些网络中如何进行路由。我们需要一个路由算法来选择两个通信进程之间的路径。我们希望路由具有以下属性:

  • 路径尽可能短
  • 路径无拥塞
  • 路由算法本身的计算成本不能太高

但这些属性之间存在权衡。例如,追求非常短的路径可能导致这些路径更拥塞。此外,我们可以考虑不同的路由计算方式:

  • 确定性路由:对于固定的源和目的地,总是使用相同的路径。这更容易计算,但可能导致该路径上的拥塞。
  • 自适应路由:尝试根据拥塞情况绕行。这可以减少拥塞,但计算路径更困难。

具体路由算法:维序路由与E立方路由 🧮

让我们看一些具体的路由算法。

  • 维序路由:在一个k维网格中,我们以某种顺序固定维度(例如X、Y、Z)。路由消息时,总是按照这个固定的维度顺序发送(例如先X方向,再Y方向,再Z方向)。
  • E立方路由:这是超立方体中的一种维序路由协议。源节点和目的节点用二进制数表示。计算路由R为源和目的二进制表示的异或。然后对R进行维序路由,从最低有效位到最高有效位,每当R中某位为1时,就在该维度上路由。

示例:在三维超立方体中,从源010路由到目的111。计算异或:010 XOR 111 = 101(即R)。从最低位开始:第一位是1,从010移动到011;第二位是0,不动;第三位是1,从011移动到111。

使用维序和E立方路由可以防止死锁。如果不使用维序路由,在某些环形通信模式下可能导致所有消息相互等待,形成死锁。而使用维序路由(例如先X后Y),可以确保消息有序传递,避免死锁。

进程到处理器映射 📍

要理解进程到处理器映射,需要区分逻辑通信模式和物理通信模式。

  • 逻辑通信模式:由程序决定。例如,程序中可能有一个进程网格,每个进程需要与其在X和Y方向上的邻居通信。
  • 物理通信模式:由实际运行的硬件决定。例如,处理器可能物理连接成一个网格。

运行程序时,需要将进程映射到处理器上。映射方式不同,性能也不同。好的映射可以获得良好性能,而差的映射会导致通信瓶颈。

示例

  • 自然映射:如果逻辑网格映射到物理网格上,每个进程的邻居都映射到相邻的物理处理器上,则通信只需单跳,没有拥塞,性能好。
  • 非自然映射:如果使用混乱的映射,进程可能需要多跳才能与逻辑邻居通信,并且某些物理链路上可能有多个逻辑通信路径穿过,导致高拥塞和长延迟,性能差。

形式化定义:我们有一个进程图P和一个处理器图R。映射函数f将每个进程p映射到处理器f(p)。将P中的边映射到R中f(p1)和f(p2)之间的最短路径上。评估映射质量的指标包括:

  • 拥塞:穿过物理网络R中一条边的路由路径的最大数量。
  • 扩张:映射后路径的最大长度(即两个在P中相邻的进程在R中的最短路径长度)。
  • 扩展:进程数除以处理器数(通常假设相等,扩展为1)。

具体映射示例 🔀

以下是一些具体拓扑之间的映射示例:

  1. 线到超立方体的映射(使用格雷码)

    • 格雷码:一种K位二进制数的排列,使得连续的两个数仅在一位上不同。可以通过递归方式构造。
    • 映射方法:将线中的第i个节点映射到K位格雷码G(i)。由于格雷码的性质,线上相邻的节点被映射到超立方体中仅一位不同的节点上,即它们在超立方体中是直接相连的。
    • 质量:扩张为1(最佳),拥塞也为1(最佳)。
  2. 超立方体到线的映射(格雷码的逆)

    • 映射方法:使用线到超立方体映射的逆函数。
    • 质量:由于超立方体的二分宽度为 ( 2^{K-1} ),而线的二分宽度为1,所有从上半部分到下半部分的消息都必须经过线中间的那条边,因此拥塞为 ( 2^{K-1} )。扩张也为 ( O(2^K) )。
  3. 网格到超立方体的映射

    • 映射方法:将网格视为线的二维推广。对于一个 ( 2^R \times 2^S ) 的网格,将其映射到 ( R+S ) 维超立方体。网格节点 (i, j) 被映射为 ( (G_R(i), G_S(j)) ),其中 ( G_R ) 和 ( G_S ) 分别是R位和S位格雷码。
    • 质量:由于网格中相邻的节点要么X坐标不同,要么Y坐标不同,而格雷码保证相邻坐标映射后仅一位不同,因此它们在超立方体中也是直接相连的。扩张为1,拥塞也为1。
  4. 超立方体到网格的映射

    • 映射方法:对于一个 ( \log P ) 维超立方体(P个节点)映射到 ( \sqrt{P} \times \sqrt{P} ) 网格。设 ( Q = \log \sqrt{P} = (\log P)/2 )。将超立方体节点二进制表示的前Q位或后Q位固定,会得到一个Q维子超立方体。将这个子超立方体映射到网格的一行或一列(使用超立方体到线的映射)。
    • 质量:由于超立方体到线的映射拥塞为 ( \sqrt{P}/2 ),而行和列的映射是独立的,因此整体拥塞为 ( \sqrt{P}/2 )。
  5. 线到网格的映射(锯齿形映射)

    • 映射方法:将线以锯齿形模式映射到网格上。
    • 质量:扩张为1,拥塞为1。
  6. 网格到线的映射(锯齿形映射的逆)

    • 映射方法:逆转线到网格的锯齿形映射。可以想象将网格拉伸成一条线。
    • 质量:拥塞为 ( \sqrt{P} ),扩张为 ( 2\sqrt{P} - 1 )。

成本性能权衡与总结 ⚖️

最后,我们看看成本性能方面的一些额外权衡。衡量网络成本的参数包括连线复杂度(网络中的边数)、二分宽度等。

考虑一个具有P个节点的网格。我们为网格中的每条边添加 ( O(\log P) ) 条额外的边,那么总边数变为 ( O(P \log P) )。一个具有P个节点的超立方体也有 ( O(P \log P) ) 条边。因此,它们在连线复杂度上成本相同。

  • 平均距离:在增强网格中,节点间的平均距离仍然是 ( O(\sqrt{P}) ),而超立方体是 ( O(\log P) )。在这方面,超立方体更优。
  • 消息传输时间:在增强网格中,由于每条链路的连线数增加了 ( \log P ) 倍,拥塞减少了 ( \log P ) 倍。发送消息的平均时间约为 ( T_s + \sqrt{P} \times T_h + (M \times T_w)/\log P )。在超立方体中,时间约为 ( T_s + \log P \times T_h + M \times T_w )。
    • 当发送大消息(( M \times T_w ) 远大于 ( T_h ))时,增强网格比超立方体快 ( \log P ) 倍。
    • 然而,在拥塞情况下,超立方体仍然更优,因为它的二分宽度(( P/2 ))远大于增强网格的二分带宽(( \sqrt{P} \log P ))。

这是一种通过保持某些成本相同来比较不同架构的方法。

总结 📝

本节课中我们一起学习了并行计算中的核心通信问题。我们从缓存一致性问题出发,探讨了失效更新两种基本协议,并深入分析了MSI协议的状态机。接着,我们了解了协议在监听缓存目录缓存系统中的实现,以及伪共享的挑战。在通信成本方面,我们建立了包含启动时间、每跳时间和传输时间的模型,并比较了存储转发分组路由直通路由的优劣。我们还讨论了拥塞的影响以及共享内存模型中成本的不确定性。对于路由算法,我们强调了路径长度、拥塞和计算复杂度之间的权衡,并介绍了维序路由E立方路由。最后,我们深入探讨了进程到处理器映射的重要性,通过格雷码等具体方法展示了如何在线网格超立方体等拓扑之间进行高效映射,并分析了映射的拥塞扩张指标。课程以不同网络架构间的成本性能权衡分析作为结束,为我们理解和设计高效并行系统提供了坚实的基础。

004:性能分析 📊

在本节课中,我们将学习如何分析并行程序的性能。我们将探讨不同的性能模型和度量方法,以理解程序在增加处理器数量时能获得多少加速,以及影响加速效果的各种因素。

性能与加速比

当我们谈论性能时,通常指的是程序的速度。如果你在一个处理器上运行一个程序,然后将该时间与使用K个处理器运行同一程序的时间进行比较,理想情况下,你希望程序运行速度快K倍。

根据加速效果,可以分为三种情况:

  • 线性加速:程序运行速度正好是K倍。
  • 次线性加速:程序运行速度有所提升,但小于K倍。
  • 超线性加速:程序运行速度超过K倍。

在实践中,我们通常得到的是次线性加速,很少能获得线性或超线性加速。

性能度量术语

为了量化性能,我们首先定义一些术语。假设我们有一个问题X和一个可以解决X的并行算法A。

  • T_S:使用一个处理器解决X所需的最短时间(即最佳串行算法的时间)。
  • T_1:算法A使用一个处理器解决X所需的时间。
  • T_P:算法A使用P个处理器解决X所需的时间(并行运行时间)。

显然,T_1 >= T_S,因为T_S是最佳串行时间。

以下是几种衡量加速比的方式:

绝对加速比与相对加速比

  • 绝对加速比 (S*_P):定义为 T_S / T_P。它比较了使用P个处理器的算法A与最佳串行算法。但问题在于,我们通常不知道T_S。
  • 相对加速比/可扩展性 (S_P):定义为 T_1 / T_P。它比较了算法A自身在使用1个处理器和使用P个处理器时的表现。这是我们本讲主要关注的度量。

工作量与效率

  • 工作量 (W_P):定义为 P * T_P。它代表了P个处理器在T_P时间内完成的总计算量。工作量至少为T_S,即 W_P >= T_S
  • 效率 (E_P):定义为 S_P / P。它衡量了处理器的利用程度。
    • 线性加速时,效率为1。
    • 次线性加速时,效率小于1。
    • 超线性加速时,效率大于1。

通常效率小于1,因为并行化会带来开销。

并行开销的来源

为什么难以达到线性加速?主要是因为并行化引入了多种开销:

  1. 通信开销:处理器之间需要交换数据。
  2. 同步开销:处理器之间需要协调步骤。
  3. 负载不均衡:任务被分割后,各处理器的工作量可能不同,导致一些处理器空闲等待。
  4. 额外计算:有时重新计算比通信传递结果更便宜,但这增加了总工作量。
  5. 算法选择:最优的串行算法可能难以并行化,因此可能需要选择更容易并行但总工作量更大的算法(例如,在最短路径问题中,可能选择Bellman-Ford算法而非Dijkstra算法)。

性能分析模型

没有一个模型能完美捕捉所有并行程序的性能。我们将介绍几种模型,每种适用于不同的情况。

DAG模型

DAG模型将计算表示为一个有向无环图

  • 节点:代表一个计算任务,节点上的权重代表计算量。
  • 有向边:代表任务间的依赖关系(前驱关系),边上的权重代表通信开销。

在这个模型中,有两个关键概念:

  • 总工作量 (C):图中所有节点和边权重的总和。
  • 关键路径长度 (D):图中最长的路径(按权重求和)的长度。它代表了必须串行执行的部分。

根据DAG模型,我们可以得到两个重要的下界(定律):

  1. 工作量定律P * T_P >= C。这意味着并行运行时间至少为 T_P >= C / P
  2. 跨度定律T_P >= T_∞ >= D。其中T_∞是使用无限多处理器时的最短时间。这意味着无论有多少处理器,运行时间至少是关键路径的长度D。

Amdahl定律(强可扩展性)

Amdahl定律假设程序由两部分组成:

  • 一个不可并行化的串行部分,占总时间的比例记为 f
  • 一个可完美并行化的部分,占总时间的比例为 1 - f

在固定问题规模的前提下,使用P个处理器时的运行时间 T_P 和加速比 S_P 为:

T_P = f * T_1 + (1 - f) * T_1 / P
S_P = T_1 / T_P = 1 / (f + (1-f)/P)

Amdahl定律给出了加速比的上限:S_P <= 1 / f。即使使用无穷多的处理器,加速比也不会超过 1/f。这解释了为什么即使并行化大部分代码,加速比也可能受限(例如,f=5%时,最大加速比不超过20倍)。

Gustafson定律(弱可扩展性)

Gustafson定律对现实情况更乐观。它假设随着处理器数量P增加,问题规模也相应增加,使得并行运行时间 T_P 保持与单处理器运行原问题的时间 T_1 相同。

在该定律下,缩放后的加速比为:

S‘_P = P - (P - 1) * f

其中f仍是串行部分在原问题中的比例。Gustafson定律预测的加速比通常远高于Amdahl定律。它反映了在实际中,我们更倾向于用更多的处理器去解决更大的问题,而不仅仅是更快地解决固定大小的问题。

选择哪个模型?

  • Amdahl定律(强扩展):适用于问题规模固定,仅增加处理器数量的场景。
  • Gustafson定律(弱扩展):适用于问题规模随处理器数量线性增长,以保持运行时间恒定的场景。
    选择取决于程序的实际使用模式。两者都未显式建模开销。

Karp-Flatt度量

为了实证地度量包含串行部分和开销在内的“非并行部分”,可以使用Karp-Flatt度量。定义 e 为串行部分与开销之和占总时间 T_1 的比例。

通过实验测量不同处理器数P下的实际加速比 S_P,可以反推出 e

e = (1/S_P - 1/P) / (1 - 1/P)

如果 e 随着P增加而增大,说明开销在增长,可扩展性不佳。如果 e 基本不变,说明程序具有良好的可扩展性。

等效率度量

等效率分析同时考虑处理器数量P和问题规模N。它旨在回答:为了在增加处理器时保持效率不变,问题规模需要以多快的速度增长?

定义:

  • W(N):有用工作量,是问题规模N的函数。
  • Γ(N, P):并行开销,是N和P的函数。

效率 E 可表示为:

E = 1 / (1 + Γ(N, P) / W(N))

为了保持E恒定,当P增加时,需要增加N,使得 W(N) 的增长速度至少与 Γ(N, P) 的增长速度一样快。W(N) 相对于 Γ(N, P) 的增长要求定义了问题的可扩展性

示例

  1. W(N) = N log N, Γ(N, P) = N log P,则需要 NP 成比例增长(N = Ω(P))以维持效率。
  2. W(N) = N^2, Γ(N, P) = N^2 log P,则无论N如何增长,效率都会随P增加而下降,因为开销与有用工作量同阶且带有附加因子。

总结

本节课我们一起学习了并行程序性能分析的核心概念与方法。我们首先定义了加速比、工作量、效率等基本术语,并探讨了导致次线性加速的各种开销。接着,我们介绍了多种性能分析模型:DAG模型提供了基于任务依赖的理论下界;Amdahl定律描述了固定问题规模下的加速上限;Gustafson定律则解释了通过扩大问题规模可能获得更高加速的原因;Karp-Flatt度量提供了一种实证评估串行部分与开销的方法;最后,等效率分析帮助我们理解如何通过调整问题规模来维持并行效率。这些工具共同为我们理解和优化并行程序性能提供了坚实的基础。

005:MPI 第一部分

在本节课中,我们将要学习如何为分布式内存机器编写并行程序,使用的工具是 MPI。

概述

上一节我们介绍了并行计算的基本概念。本节中,我们来看看一种具体的并行编程模型——消息传递接口。我们将学习 MPI 的基本概念、编程模型以及如何使用它进行进程间通信。

分布式内存架构

首先,让我们回顾一下分布式内存架构。

观察左侧的图表,分布式内存架构包含多个不同的处理器。每个处理器都有自己的内存,可以直接访问。然而,如果一个处理器想要访问另一个处理器的内存,它需要通过网络发送消息。接收消息的处理器从其自身内存中获取数据,然后发送回初始处理器。这就是分布式内存处理。

接下来,观察右侧的图表。这里展示的是分布式内存架构和共享内存架构的结合。我们拥有多个节点,每个节点内部有多个 CPU 和一块内存。节点内的所有 CPU 都可以访问这块内存的任何部分。但是,如果一个节点内的 CPU 想要访问另一个节点的内存,它同样需要通过网络发送消息,然后接收处理器获取该内存数据并发送回第一个处理器。因此,在每个节点内部,我们处于共享内存环境;而在不同节点之间,我们处于分布式内存环境。今天我们将讨论如何为这些分布式内存机器编程。

MPI 简介

我们主要使用标准 C、C++ 或 Fortran 语言编程,但这些语言本身不支持分布式内存操作。因此,我们需要一个库来处理分布式内存通信。虽然有多个库可用,但最标准、使用最广泛的是 MPI。

MPI 代表消息传递接口。这个库由学术界和工业界在 90 年代开发。在 90 年代初期之前,存在多种相互竞争的分布式内存编程方法,情况相当混乱,因为使用不同库编写的程序彼此不兼容。最终,人们聚集起来,希望标准化这个过程,创建一个所有人都能使用的库来进行分布式内存处理。

开发 MPI 时有几个目标:

  • 可移植性:希望 MPI 代码能在不同架构上编译和运行。
  • 高效性:添加了各种功能以提高效率。
  • 通用性与灵活性:能够编写任何类型的分布式内存程序。

MPI 有几个版本。第一个版本从 90 年代中期持续到大约 2008 年。MPI 2.0 于 2008 年发布,MPI 3.0 则在 2015 年发布。MPI 是一个接口规范,它定义了库必须支持的函数,但不规定如何实现这些函数。不同的供应商在实现 MPI 时,会针对自己的硬件进行优化。然而,所有支持 MPI 的供应商都支持相同的编程接口,因此你编写的 MPI 程序具有可移植性。

流行的 MPI 实现包括 MPICH 和 Open MPI。此外,英特尔、微软等硬件供应商也提供商业实现。

MPI 程序结构

一个 MPI 程序主要包含四种类型的函数:

  1. 进程创建与管理函数:用于启动和初始化程序。
  2. 通信函数:这是 MPI 的核心,用于在进程间发送和接收消息。它又分为两类:
    • 点对点通信:在两个特定进程之间发送或接收消息。
    • 集体通信:在一组进程之间进行通信,例如广播。
  3. 数据类型定义函数:MPI 支持基本数据类型,也允许用户定义新的数据类型。
  4. 通信组与拓扑函数:用于创建和管理通信组。通信组是一组可以相互通信的进程集合。在 MPI 中进行通信时,必须指定使用哪个通信组。还可以在组上定义虚拟拓扑,以优化通信。

在本次讲座的剩余部分,我们将重点学习点对点和集体通信函数。

需要强调的是,在 MPI 模型中,一个进程无法直接读取另一个进程的数据,必须通过发送和接收消息来完成。这与共享内存编程模型形成对比,在共享内存中,所有进程共享一块公共内存,任何进程都可以通过正常的读写操作访问它。不过,近十年发布的新版 MPI 也部分支持共享内存风格的编程,但 MPI 主要还是用于消息传递类型的程序。

MPI 进程模型:SPMD

MPI 使用一种称为 SPMD 的进程模型,它代表单程序多数据。

这意味着对于一个 MPI 程序,所有处理器都将运行同一份代码。如右图所示,我们有一个源代码文件,编译后生成一个可执行文件。为了并行运行程序,我们将同一个可执行文件发送给所有不同的进程。因此,我们在不同处理器上运行的是同一个程序。

当然,我们不希望所有处理器做完全相同的事情,那样只是重复工作。我们希望它们运行相同的程序,但操作不同的数据片段。实现这一点的关键是,每个进程都有一个唯一的 ID。在 MPI 程序中,你可以根据进程的 ID 来指定不同的进程执行不同的操作。

需要指出的是,SPMD 与我们之前见过的 SIMD 非常不同。SIMD 是一种硬件架构,意味着多个处理单元在同一时间点执行完全相同的指令,处理器之间是同步的。而在 MPI 的 SPMD 模型中,进程运行相同的程序,但进程之间没有隐式的同步。一个进程可能处于程序的一个点,而另一个进程可能处于完全不同的另一个点。

一个处理器上可以运行多个 MPI 进程。例如,有 10 个处理器和 20 个 MPI 进程,你可以在每个处理器上放置两个进程。但通常,你会让进程数与处理器核心数一致,以避免资源争用。不过,如果进程工作量很小,有时也会在一个处理器上运行多个进程以充分利用资源。

在 MPI 中,程序员通常无法控制进程到处理器的映射。

编写第一个 MPI 程序

现在,让我们实际看看如何在 MPI 中创建进程。

首先,要使用 MPI,必须包含 MPI 头文件。然后,为了启动 MPI 程序,需要调用 MPI_Init 函数。使用完 MPI 后,需要调用 MPI_Finalize

以下是一个简单的 MPI “Hello World” 程序示例:

#include <mpi.h>
#include <stdio.h>

int main(int argc, char** argv) {
    int num_procs, my_rank;

    MPI_Init(&argc, &argv);
    MPI_Comm_size(MPI_COMM_WORLD, &num_procs);
    MPI_Comm_rank(MPI_COMM_WORLD, &my_rank);

    printf("From process %d out of %d: Hello World\n", my_rank, num_procs);

    MPI_Finalize();
    return 0;
}

程序解释:

  • #include <mpi.h>:包含 MPI 头文件。
  • num_procs:存储进程总数。
  • my_rank:存储当前进程的 ID。
  • MPI_Init(&argc, &argv):初始化 MPI 环境。
  • MPI_Comm_size(MPI_COMM_WORLD, &num_procs):获取默认通信器 MPI_COMM_WORLD 中的进程总数。MPI_COMM_WORLD 是 MPI 启动时创建的包含所有进程的默认通信器。
  • MPI_Comm_rank(MPI_COMM_WORLD, &my_rank):获取当前进程在 MPI_COMM_WORLD 中的排名。
  • printf:每个进程打印自己的排名和总进程数。
  • MPI_Finalize():结束 MPI 环境。

要运行此程序,在安装 MPI 后,可以使用类似以下的命令:

mpirun -np 8 ./my_program

这将启动 8 个进程。你会在运行命令的主机上看到 8 条消息。但消息的打印顺序不一定按照排名 0, 1, 2... 的顺序,因为进程之间没有同步,运行快的进程会先打印。

另一种模型:MPMD

在继续之前,需要提及分布式内存编程的另一种模型:MPMD,即多程序多数据。

在 MPMD 模型中,通常采用主从方法。开始时只有一个主进程。当主进程发现有很多工作需要并行处理时,它会执行生成操作来创建其他从进程。从进程运行的代码可能与主进程不同。与 MPI 的 SPMD 模型不同,MPMD 模型的进程数是动态变化的,进程可以随时创建。这会带来一些创建开销,但编程方式可能更自然。例如,PVM 库支持这种模型。然而,MPI 是目前最流行的分布式内存编程方式,因此我们将重点学习它。

MPI 函数概览与通信器

MPI 拥有数百个函数,但编写高效通用的 MPI 程序通常只需要掌握其中一小部分。这些函数包括:

  • 初始化函数:如 MPI_Init, MPI_Finalize
  • 点对点通信函数:如 MPI_Send, MPI_Recv
  • 集体通信函数:如广播、散播、聚集、全局归约、扫描、屏障等。
  • 派生数据类型创建函数:如连续、索引、结构、向量等类型。
  • 通信器创建与拓扑定义函数

我们不会逐一讲解所有函数,但会展示每种类型的例子。如需完整列表,可查阅 MPI 文档。

现在,让我们更详细地讨论通信器。

通信器,也称为通信域,是一组可以相互通信的进程的集合。在一个程序中,可以创建多个通信器。你可以将进程分成多个组,每个组是一个通信器,一个进程可以同时属于多个通信器。使用通信器的目的是帮助组织通信。

例如,假设我们有一个进程网格。可以定义一个包含所有进程的通信器。此外,还可以为每一行进程定义一个行通信器,因为有时你只想与同一行的进程通信。同样,也可以定义列通信器。这样,当只想与同列进程通信时,使用列通信器即可,而无需使用整个通信器。

通信器具有类型 MPI_Comm。进行任何类型的通信时,都必须指定一个通信器。调用 MPI_Init 后,MPI 会自动创建一个名为 MPI_COMM_WORLD 的默认通信器。

通信器有大小,可以通过 MPI_Comm_size 获取。通信器内的每个进程都有一个排名,可以通过 MPI_Comm_rank 获取。排名用于区分不同进程,使它们能基于排名执行不同的操作。

点对点通信:发送与接收

接下来,我们看看如何实际发送消息。这是 MPI 中最基本的操作。

要发送消息,调用 MPI_Send 函数。其参数如下:

  • buf:指向要发送数据的指针。
  • count:发送的数据项数量。
  • datatype:发送数据的 MPI 数据类型。
  • dest:目标进程在指定通信器中的排名。
  • tag:消息标签,用于区分发送到同一目的地的多个消息。
  • comm:通信器。

在一对进程之间,所有发送和接收的消息都按先进先出顺序处理。

要接收消息,调用 MPI_Recv 函数。其参数如下:

  • buf:指向用于存储接收数据的缓冲区的指针。
  • count:计划接收的数据项最大数量。
  • datatype:期望接收数据的 MPI 数据类型。
  • source:源进程在指定通信器中的排名。可以使用 MPI_ANY_SOURCE 表示接受来自任何进程的消息。
  • tag:期望接收消息的标签。可以使用 MPI_ANY_TAG 表示接受任何标签的消息。
  • comm:通信器。
  • statusMPI_Status 对象,用于获取接收操作的详细信息,例如当使用 MPI_ANY_SOURCEMPI_ANY_TAG 时,可以通过它查询实际的消息来源和标签。

如果多个发送都匹配一个接收(例如使用 MPI_ANY_SOURCE),则只有一个发送能完成接收,其他的可能会被“丢失”或等待。

发送接收示例与阻塞问题

让我们看一个使用 MPI_SendMPI_Recv 的简单示例。假设只有两个进程,排名为 0 和 1。目标是让进程 0 发送一条消息给进程 1,然后接收一条返回消息;进程 1 先接收消息,然后发送回复。

if (my_rank == 0) {
    MPI_Send(&data_to_send, 1, MPI_CHAR, 1, tag, MPI_COMM_WORLD);
    MPI_Recv(&data_received, 1, MPI_CHAR, 1, tag, MPI_COMM_WORLD, &status);
} else if (my_rank == 1) {
    MPI_Recv(&data_received, 1, MPI_CHAR, 0, tag, MPI_COMM_WORLD, &status);
    MPI_Send(&data_to_send, 1, MPI_CHAR, 0, tag, MPI_COMM_WORLD);
}

使用这些函数时需要小心,因为 MPI_Send 是阻塞函数。这意味着调用 MPI_Send 后,进程会阻塞,直到发送成功完成。这是为了确保数据被正确发送,因为 MPI 进程之间不同步,发送方需要等待接收方准备好接收,以避免消息丢失。

MPI_Send 阻塞的时间长短取决于 MPI 实现是否具有硬件发送缓冲区。如果有缓冲区,MPI_Send 只需等待数据从用户缓冲区复制到系统缓冲区即可返回。如果没有缓冲区,发送方必须一直等待,直到接收方准备好并完成接收操作。

另一方面,MPI_Recv 总是阻塞的,因为调用它意味着进程希望接收数据,在收到数据之前它会一直等待。

非阻塞通信

有时我们希望进行非阻塞通信,这样在发出通信请求后,进程可以继续执行其他计算。这可以通过非阻塞通信函数实现。

非阻塞发送函数是 MPI_Isend,非阻塞接收函数是 MPI_Irecv。这些函数调用后会立即返回,不会阻塞。但是,你需要知道这些操作何时完成,因为在操作完成前,不能修改发送缓冲区或使用接收缓冲区中的数据。

为了检查操作是否完成,可以使用 MPI_Test 函数,它接受一个 MPI_Request 对象(由 MPI_IsendMPI_Irecv 返回)并检查操作状态。如果需要等待操作完成,可以使用 MPI_Wait 函数,它会阻塞直到指定请求对应的操作完成。

死锁与避免

使用发送和接收函数时,另一个需要注意的问题是死锁。死锁意味着系统中的所有进程都卡在某个点,无法继续执行,程序完全冻结。

以下是死锁可能发生的例子:

例子1:标签顺序不匹配
进程0先发送标签1的消息,再发送标签2的消息。进程1先尝试接收标签2的消息,再接收标签1的消息。如果没有缓冲,进程0会阻塞在发送标签1的消息上,等待进程1接收;而进程1阻塞在接收标签2的消息上,等待进程0发送。两者互相等待,导致死锁。如果有缓冲区,则可能避免。修复方法是确保发送和接收的标签顺序一致。

例子2:环状通信
假设有多个进程连接成一个环,每个进程都想发送消息给下一个进程,并从上一个进程接收消息。一个简单的写法是每个进程都先执行 MPI_Send 给下一个进程,然后执行 MPI_Recv 从上个进程接收。如果没有缓冲,所有进程都会阻塞在 MPI_Send 上,因为没有人执行 MPI_Recv,从而导致死锁。

避免这种环状死锁的一种方法是让一半的进程(例如偶数排名进程)先发送后接收,另一半的进程(奇数排名进程)先接收后发送。这样就能打破循环等待。

然而,对于复杂拓扑,编写无死锁算法可能很繁琐。一个更好的方法是使用一个能同时完成发送和接收的函数:MPI_Sendrecv。这个函数结合了发送和接收,能自动避免许多常见的死锁情况。例如,在环状通信中,可以简单地调用 MPI_Sendrecv 来同时处理发送给下一个进程和接收自上一个进程的操作。

综合示例:奇偶交换排序

最后,让我们通过一个更复杂的算法——奇偶交换排序,来综合运用这些 MPI 函数。

奇偶交换排序是一种并行排序算法。假设有 n 个进程,算法进行 n 个阶段(奇偶交替)。在奇数阶段,进程按 (0,1), (2,3), ... 配对;在偶数阶段,进程按 (1,2), (3,4), ... 配对。配对的进程比较并交换(或归并)它们持有的数据(可能多个值),使得数据在进程间逐渐有序。

算法的 MPI 实现核心步骤如下:

  1. 初始化 MPI,获取进程总数和本进程排名。
  2. 每个进程初始化自己本地的一组数据并排序。
  3. 根据当前阶段(奇/偶)和本进程排名的奇偶性,计算配对进程的排名。
  4. 进行 n 个阶段循环:
    • 使用 MPI_Sendrecv 与配对进程交换数据(避免死锁)。
    • 调用一个 compare_split 函数,将接收到的数据与本地数据合并,然后根据本进程是“左”进程还是“右”进程,保留较小的一半或较大的一半数据。
  5. n 个阶段后,所有数据在整个进程集合中排序完成。

这个算法虽然不是最高效的排序算法,但并行度很高,因为在每个阶段,不同的进程对可以同时工作。

总结

本节课中,我们一起学习了 MPI 的基础知识。我们了解了分布式内存架构与 MPI 的作用,掌握了 MPI 的 SPMD 编程模型,并编写了第一个简单的 MPI 程序。我们深入探讨了通信器的概念,学习了如何进行点对点通信,包括阻塞的 MPI_Send/MPI_Recv 和非阻塞的 MPI_Isend/MPI_Irecv。我们还认识了死锁问题及其避免方法,特别是通过 MPI_Sendrecv 函数。最后,我们通过奇偶交换排序的例子,看到了如何将这些概念组合起来解决实际问题。MPI 是高性能计算中至关重要的工具,掌握其基础将为编写复杂的分布式并行程序打下坚实基础。

006:第6讲 MPI 第二部分 - 集体通信与高级特性 🚀

在本节课中,我们将深入学习MPI中的集体通信操作,以及一些高级特性,如自定义数据类型、通信器管理和虚拟拓扑。我们将通过具体的例子和代码片段来理解这些概念,并学习如何将它们应用到实际问题中,例如矩阵向量乘法。


概述 📋

上一节我们介绍了MPI的点对点通信。本节中,我们来看看MPI的集体通信操作。集体通信允许一组进程之间进行协调的数据交换,这对于许多并行算法至关重要。我们将学习广播、归约、扫描、聚集、散播等操作,并了解如何定义自定义数据类型、创建新的通信器以及利用虚拟拓扑优化进程布局。


集体通信简介

集体通信是指在一组进程(一个通信器内)之间进行的协调通信例程。与点对点通信不同,集体通信通常涉及通信器中的所有进程。

集体通信主要分为三种类型:

  • 一对多:一个进程与多个其他进程通信。
  • 多对一:多个进程向一个进程发送数据。
  • 多对多:所有进程相互通信。

一种简单的实现集体通信的方法是使用多个点对点通信。例如,要实现广播,发送进程可以向其他每个进程单独发送消息。但这效率不高,因为单个进程需要承担所有工作。MPI库提供了更高效的内部实现。

在MPI 1和2中,集体通信操作是阻塞的。在MPI 3中,引入了非阻塞的集体通信操作。


集体通信原语详解

与点对点通信一样,集体通信也需要指定进行通信的进程组(通信器)。

以下是我们将要详细探讨的几种主要集体通信原语,我们可以通过视觉化表示来理解它们。假设有P个进程,用网格的行表示不同进程,列表示来自不同进程的数据。

广播

广播操作中,假设一个进程(例如进程0)拥有一份数据。执行广播后,所有其他进程都将拥有这份相同的数据。

MPI_Bcast 函数原型:

int MPI_Bcast(void *buffer, int count, MPI_Datatype datatype, int root, MPI_Comm comm)
  • buffer: 包含要广播数据的缓冲区指针。对于根进程,这是发送缓冲区;对于其他进程,这是接收缓冲区。
  • count: 缓冲区中数据项的数量。
  • datatype: 数据项的类型(如 MPI_FLOAT)。
  • root: 执行广播的源进程的秩。
  • comm: 通信器。

重要说明:在SPMD(单程序多数据)编程模型下,所有进程都执行相同的代码。因此,所有进程(包括根进程和非根进程)都会调用 MPI_Bcast。调用时,进程根据自身的秩与 root 参数的关系来决定行为:

  • 如果进程秩等于 root,则执行发送操作。
  • 如果进程秩不等于 root,则执行接收操作。

可以将 MPI_Bcast 理解为“我将参与一个广播”,具体参与方式(发送或接收)由调用者的秩决定。

示例:根进程(秩为0)广播整数值1。

int value = 1;
MPI_Bcast(&value, 1, MPI_INT, 0, MPI_COMM_WORLD);
// 执行后,所有进程的 `value` 变量都变为1。

归约

归约操作将来自通信器中所有进程的数据,通过某种运算(如求和、求积、求最大值等)组合起来,并将结果存储在指定的目标进程中。

MPI_Reduce 函数原型:

int MPI_Reduce(const void *sendbuf, void *recvbuf, int count, MPI_Datatype datatype, MPI_Op op, int root, MPI_Comm comm)
  • sendbuf: 每个进程的发送缓冲区地址,包含本地数据。
  • recvbuf: 仅根进程需要提供的接收缓冲区地址,用于存放归约结果。
  • count: 每个进程发送/接收的数据项数量。
  • datatype: 数据项类型。
  • op: 归约操作(如 MPI_SUM, MPI_PROD, MPI_MAX, MPI_MIN, MPI_MAXLOC 等)。
  • root: 接收结果的根进程的秩。
  • comm: 通信器。

与广播类似,所有进程都调用 MPI_Reduce。根进程收集并组合数据,其他进程则发送其本地数据。

示例:假设有4个进程,每个进程有一个包含两个整数的向量。使用 MPI_SUM 归约到进程0。

  • 进程0数据: [5, 1]
  • 进程1数据: [2, 3]
  • 进程2数据: [7, 8]
  • 进程3数据: [4, 2]
    执行 MPI_Reduce(..., MPI_SUM, 0, ...) 后,进程0的 recvbuf 中得到结果 [5+2+7+4, 1+3+8+2] = [18, 14]

特殊操作 MPI_MAXLOC:此操作返回最大值及其所在进程的秩。对于上述数据,对第一个元素使用 MPI_MAXLOC,结果可能是一个结构体 {max_value=7, rank=2}


全归约

全归约与归约类似,区别在于所有进程都会收到归约后的结果,而不仅仅是根进程。因此,函数签名中不需要指定 root 参数。

MPI_Allreduce 函数原型:

int MPI_Allreduce(const void *sendbuf, void *recvbuf, int count, MPI_Datatype datatype, MPI_Op op, MPI_Comm comm)
  • recvbuf: 所有进程都需要提供的接收缓冲区。

扫描(前缀操作)

扫描操作(或称前缀操作)对进程秩顺序排列的数据进行累积操作。每个进程得到的是从秩0进程到自身秩的所有数据的累积结果。

MPI_Scan 函数原型:

int MPI_Scan(const void *sendbuf, void *recvbuf, int count, MPI_Datatype datatype, MPI_Op op, MPI_Comm comm)
  • sendbuf: 每个进程的本地数据。
  • recvbuf: 每个进程的接收缓冲区,存放前缀结果。
  • op: 操作(如 MPI_SUM, MPI_PROD 等)。

示例:4个进程的数据分别为 [1, 2, 3, 4]

  • 使用 MPI_SUM 扫描:
    • 进程0结果: 1
    • 进程1结果: 1+2 = 3
    • 进程2结果: 1+2+3 = 6
    • 进程3结果: 1+2+3+4 = 10
  • 使用 MPI_PROD 扫描:
    • 进程0结果: 1
    • 进程1结果: 1*2 = 2
    • 进程2结果: 1*2*3 = 6
    • 进程3结果: 1*2*3*4 = 24

当操作为 MPI_SUM 时,此操作常被称为前缀和。前缀和在并行计算中应用非常广泛。


聚集

聚集操作是散播的逆操作。每个进程拥有自己的数据,这些数据被收集到指定的目标进程中。

MPI_Gather 函数原型:

int MPI_Gather(const void *sendbuf, int sendcount, MPI_Datatype sendtype, void *recvbuf, int recvcount, MPI_Datatype recvtype, int root, MPI_Comm comm)
  • sendbuf: 每个进程的发送缓冲区。
  • sendcount: 每个进程发送的数据项数量。
  • recvbuf: 仅根进程需要提供的接收缓冲区。如果通信器有 k 个进程,每个进程发送 sendcount 个数据,则根进程的接收缓冲区必须能容纳 k * sendcount 个数据项。
  • recvcount: 根进程期望从每个进程接收的数据项数量(通常等于 sendcount)。
  • root: 目标进程的秩。

所有进程调用 MPI_Gather。根进程收集数据,其他进程发送数据。


全聚集

全聚集操作意味着每个进程都执行一次聚集操作。因此,每个进程最终都会拥有来自所有进程的数据。

MPI_Allgather 函数原型:

int MPI_Allgather(const void *sendbuf, int sendcount, MPI_Datatype sendtype, void *recvbuf, int recvcount, MPI_Datatype recvtype, MPI_Comm comm)
  • recvbuf: 所有进程都需要提供的接收缓冲区,大小需能容纳来自所有进程的数据。


散播

散播操作与广播类似,但源进程向其他每个进程发送的是不同的数据块。

MPI_Scatter 函数原型:

int MPI_Scatter(const void *sendbuf, int sendcount, MPI_Datatype sendtype, void *recvbuf, int recvcount, MPI_Datatype recvtype, int root, MPI_Comm comm)
  • sendbuf: 仅根进程需要提供的发送缓冲区。假设有 k 个进程,每个进程接收 recvcount 个数据,则根进程的发送缓冲区需包含 k * recvcount 个数据项。数据按进程秩顺序分块。
  • sendcount: 根进程发送给每个进程的数据项数量(通常等于 recvcount)。
  • recvbuf: 所有进程的接收缓冲区。
  • root: 源进程的秩。

向量散播

标准散播要求发送给每个进程的数据量相同。MPI_Scatterv 允许向不同进程发送不同数量的数据。

MPI_Scatterv 函数原型:

int MPI_Scatterv(const void *sendbuf, const int sendcounts[], const int displs[], MPI_Datatype sendtype, void *recvbuf, int recvcount, MPI_Datatype recvtype, int root, MPI_Comm comm)
  • sendcounts[]: 一个数组,指定发送给每个进程的数据项数量。
  • displs[]: 一个数组,指定发送缓冲区中,发给每个进程的数据的起始偏移量(以 sendtype 的项数为单位)。

displs 数组通常通过计算 sendcounts前缀和来获得(第一个位移为0)。这是一个前缀和的简单应用。

对于接收进程,sendcountsdispls 参数仅对根进程有意义;其他进程可以忽略它们。


全到全

在全到全操作中,每个进程都向所有其他进程发送数据,同时也从所有其他进程接收数据。可以理解为每个进程都执行了一次散播。

MPI_Alltoall 函数原型:

int MPI_Alltoall(const void *sendbuf, int sendcount, MPI_Datatype sendtype, void *recvbuf, int recvcount, MPI_Datatype recvtype, MPI_Comm comm)
  • sendbuf: 发送缓冲区,包含要发送给所有进程的数据,按目标进程秩排列。
  • sendcount: 发送给每个其他进程的数据项数量。
  • recvbuf: 接收缓冲区,将从所有进程接收数据,按源进程秩排列。
  • recvcount: 从每个其他进程接收的数据项数量。


向量全到全

MPI_AlltoallvMPI_Alltoall 的变体,允许每个进程向其他进程发送不同数量的数据,也从其他进程接收不同数量的数据。它需要更复杂的参数来指定发送/接收计数和位移。


屏障

屏障操作用于同步一个通信器中的所有进程。调用 MPI_Barrier 的进程将在此处阻塞,直到通信器内的所有进程都调用了该函数,然后它们才能继续执行后续代码。

MPI_Barrier 函数原型:

int MPI_Barrier(MPI_Comm comm)

使用屏障可以将程序执行划分为多个阶段,确保所有进程完成一个阶段后,才同时进入下一个阶段。

注意:必须确保所有进程最终都能到达屏障,否则程序将永远等待。


应用实例:矩阵向量乘法

现在,我们来看如何利用集体通信操作解决一个实际问题:矩阵向量乘法 y = A * x。我们假设矩阵 A 按列分块存储在不同进程中,向量 x 也相应地被分块。

算法步骤

  1. 局部计算:每个进程计算自己拥有的矩阵列块与本地向量 x 分量的点积,得到部分结果向量(长度为矩阵行数 n)。
  2. 全局归约:所有进程通过 MPI_Reduce(使用 MPI_SUM 操作)将它们计算出的部分结果向量相加。结果被收集到根进程(例如进程0),形成完整的向量 y
  3. 结果散播:根进程使用 MPI_Scatter 将完整的向量 y 分发给所有进程,使每个进程获得最终结果中属于自己的那一部分。

代码框架

// 假设每个进程拥有:局部矩阵 A_local[n][n_local],局部向量 x_local[n_local]
// 目标:计算 y_local[n_local]

double partial_y[n]; // 存储局部点积结果
double y[n]; // 仅根进程需要,用于存储完整的y
double y_local[n_local]; // 每个进程的最终结果部分

// 1. 局部计算:对每一行i,计算 A_local[i][:] 与 x_local 的点积
for (int i = 0; i < n; i++) {
    partial_y[i] = 0.0;
    for (int j = 0; j < n_local; j++) {
        partial_y[i] += A_local[i][j] * x_local[j];
    }
}

// 2. 全局归约:将所有进程的 partial_y 求和到根进程的 y 中
MPI_Reduce(partial_y, y, n, MPI_DOUBLE, MPI_SUM, 0, MPI_COMM_WORLD);

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/shtech-cs121-prll-comp/img/b86ffbf11a59eb7f2efdfca6f383fe37_55.png)

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/shtech-cs121-prll-comp/img/b86ffbf11a59eb7f2efdfca6f383fe37_57.png)

// 3. 结果散播:根进程将 y 散播到所有进程的 y_local 中
MPI_Scatter(y, n_local, MPI_DOUBLE, y_local, n_local, MPI_DOUBLE, 0, MPI_COMM_WORLD);


高级MPI特性

派生数据类型

MPI允许用户定义自己的数据类型,以便于发送结构体或非连续内存数据。定义后需使用 MPI_Type_commit 提交。

主要派生数据类型构造函数:

  1. MPI_Type_contiguous: 定义连续存放的多个相同类型数据。
    MPI_Datatype float4;
    MPI_Type_contiguous(4, MPI_FLOAT, &float4);
    MPI_Type_commit(&float4);
    
  2. MPI_Type_vector: 定义具有固定步长的向量(如矩阵的一列)。
    // 从4x4矩阵(行主序)中取一列(4个元素,步长为4)
    MPI_Datatype column_type;
    MPI_Type_vector(4, 1, 4, MPI_FLOAT, &column_type); // count=4, blocklength=1, stride=4
    MPI_Type_commit(&column_type);
    
  3. MPI_Type_indexed: 允许指定不同的位移来选取数据块。
  4. MPI_Type_struct: 定义结构体,包含不同类型的成员。
    typedef struct {
        float pos[4];
        int id[2];
    } Particle;
    
    // 创建 Particle 类型
    MPI_Datatype particle_type;
    int blocklengths[2] = {4, 2};
    MPI_Aint displacements[2];
    MPI_Datatype types[2] = {MPI_FLOAT, MPI_INT};
    
    // 计算位移
    displacements[0] = offsetof(Particle, pos);
    displacements[1] = offsetof(Particle, id);
    
    MPI_Type_create_struct(2, blocklengths, displacements, types, &particle_type);
    MPI_Type_commit(&particle_type);
    

使用完派生数据类型后,应使用 MPI_Type_free 释放。


通信器管理

除了默认的 MPI_COMM_WORLD,MPI允许创建新的通信器,将进程分组。

示例:分割通信器

MPI_Comm new_comm;
int color = my_rank / 4; // 将8个进程分为两组 (0-3) 和 (4-7)
MPI_Comm_split(MPI_COMM_WORLD, color, my_rank, &new_comm);

int new_rank;
MPI_Comm_rank(new_comm, &new_rank);
// 现在,进程可以在新的、更小的通信器内进行集体通信

虚拟拓扑

虚拟拓扑允许用户为进程定义逻辑布局(如网格、环面、图),以匹配算法的通信模式。这有助于MPI运行时将进程映射到物理处理器拓扑上,优化通信性能。

示例:创建二维笛卡尔网格拓扑

int dims[2] = {4, 4}; // 4x4网格
int periods[2] = {0, 0}; // 非周期性(非环面)
MPI_Comm cart_comm;
MPI_Cart_create(MPI_COMM_WORLD, 2, dims, periods, 1, &cart_comm);

int coords[2];
MPI_Cart_coords(cart_comm, my_rank, 2, coords); // 获取本进程在网格中的坐标

// 查找邻居进程
int up, down, left, right;
MPI_Cart_shift(cart_comm, 0, 1, &up, &down);   // 维度0(Y方向)的邻居
MPI_Cart_shift(cart_comm, 1, 1, &left, &right); // 维度1(X方向)的邻居

MPI版本演进

  • MPI-1: 提供了核心的点对点和集体通信、派生数据类型、通信器管理等。
  • MPI-2: 增加了动态进程创建、跨通信器的集体操作、并行I/O、单边通信(RMA - Remote Memory Access)。
  • MPI-3: 引入了非阻塞集体操作、改进的单边通信、更好的多线程支持。

单边通信允许一个进程直接访问另一个进程的内存,而无需目标进程显式调用接收函数,为某些编程模式提供了更大的灵活性。


总结 🎯

本节课中我们一起学习了MPI集体通信的核心概念和操作,包括广播、归约、扫描、聚集、散播及其变体,以及屏障同步。我们通过矩阵向量乘法的例子,看到了如何将这些操作组合起来解决实际问题。此外,我们还探讨了MPI的一些高级特性,如派生数据类型、通信器分割和虚拟拓扑创建,这些工具能帮助我们编写更高效、更结构化的并行程序。最后,我们简要回顾了MPI标准的演进。下一讲,我们将深入探讨这些集体通信操作在底层是如何高效实现的。

007:集体通信操作的实现 🚀

在本节课中,我们将学习如何高效地实现MPI中的集体通信操作。上一讲我们探讨了如何使用这些操作来解决问题,本节我们将深入其背后的并行算法实现。

概述 📋

集体通信操作(如广播、规约等)是许多并行算法的核心。虽然可以通过多次点对点通信来实现,但通常存在更高效的并行通信算法。我们将首先定义一个通信成本模型,然后分析在不同网络架构(如环、网格、超立方体)上实现各种集体操作的方法及其时间复杂度。

通信成本模型 💰

在讨论算法之前,我们需要定义一个通信成本模型。这个模型基于我们之前关于网络通信的讨论。

发送一个大小为 M 的消息,其成本主要由两部分构成:

  • 启动时间:在发送消息前需要进行的启动操作,时间为 Ts
  • 传输时间:发送每个字(word)需要时间 Tw。发送 M 个字的总时间为 M * Tw

因此,发送一个大小为 M 的消息的总时间 T 可以表示为:
T = Ts + M * Tw

在这个模型中,我们忽略了消息传输的距离(即网络跳数带来的延迟),因为该延迟通常远小于 TsM * Tw。同时,如果发送过程中存在争用(Contention),例如有 C 个进程试图同时使用同一条链路发送 M 个字,那么所有发送完成的时间将是:
T = C * (Ts + M * Tw)

我们将基于这个模型,分析在不同通信架构(如环网、网格、超立方体)上实现集体操作的成本。我们将要分析的操作包括:

  • 广播与规约
  • 全广播与全规约
  • 前缀和
  • 散发与收集
  • 全散发与全收集
  • 循环移位

环网上的广播算法 🔄

假设一个包含 P 个进程的环,其中一个进程(如进程0)拥有一个值,并希望将其广播给所有其他进程。

一种简单的方法是进程0依次向其他 P-1 个进程发送消息,但这是一种串行算法,速度较慢。我们可以采用并行算法来加速。

以下是并行广播算法的步骤:

  1. 第一步:进程0将消息发送给环上距离为 P/2 的进程(即环的另一半)。例如,在8个进程的环中,进程0发送给进程4。
  2. 第二步:现在进程0和进程4都拥有消息。它们同时将消息发送给距离为 P/4 的进程。进程0发送给进程2,进程4发送给进程6。
  3. 第三步:现在进程0、2、4、6都拥有消息。它们同时将消息发送给距离为1的邻居进程。最终,所有8个进程都获得了消息。

这个算法通过让获得消息的进程“帮助”源进程进行广播,实现了并行化。

环网上的规约算法 ➕

规约操作是广播的逆过程。假设环上每个进程都有一个值,我们希望将所有值求和(规约)到进程0。

由于规约是广播的逆操作,我们可以简单地反转广播算法中的所有通信步骤来实现规约:

  1. 第一步(对应广播的最后一步):进程7发送给进程6,进程5发送给进程4,进程3发送给进程2,进程1发送给进程0。接收方将接收到的值加到自己的值上。
  2. 第二步:进程6发送其当前和(6+7)给进程4,进程2发送其当前和(2+3)给进程0。接收方再次进行加法。
  3. 第三步:进程4发送其当前和(4+5+6+7)给进程0。进程0将其加到自己的和(0+1+2+3)上,最终得到所有8个值的总和。

时间复杂度分析 ⏱️

对于环网上的广播和规约算法:

  • 步骤数:在广播算法中,每一步拥有消息的进程数翻倍。因此,经过 log₂(P) 步后,所有 P 个进程都获得了消息。规约算法步数相同。
  • 每步成本:每一步,进程发送一个大小为 M 的消息,成本为 Ts + M * Tw
  • 总成本:总时间 T 为:
    T = (Ts + M * Tw) * log₂(P)

网格上的广播算法 #️⃣

一个二维网格可以看作是环的推广。在 √P × √P 的网格上实现广播,可以分两个阶段进行:

  1. 行广播:首先,源进程(假设为(0,0))在其所在行进行广播。这相当于在一个大小为 √P 的环上进行广播,需要 log₂(√P) 步。
  2. 列广播:完成行广播后,该行所有进程都拥有了消息。然后,这些进程同时在各自的列上进行广播。这同样需要 log₂(√P) 步。

总步数为 2 * log₂(√P) = log₂(P)。每步发送大小为 M 的消息,因此总时间与环网广播相同:
T = (Ts + M * Tw) * log₂(P)

超立方体上的广播算法 🧊

在超立方体上,我们可以利用其层次结构进行高效的广播。一个 P 个进程的超立方体有 log₂(P) 维。

算法按维度递归地进行:

  1. 第一步:源进程(如000)将消息发送给在最高维上相反的邻居(如100)。这样,问题被分解为在两个低一维的超立方体(前四面体和后四面体)上分别进行广播。
  2. 后续步骤:在每个低维超立方体内,重复此过程:拥有消息的进程将消息发送给在下一个维度上相反的邻居,进一步分解问题。
  3. 经过 log₂(P) 步后,所有进程都获得了消息。

每一步发送一个大小为 M 的消息,总时间同样为:
T = (Ts + M * Tw) * log₂(P)

环网上的全广播算法 🌐

全广播是指每个进程都拥有一个值,并希望将自己的值广播给所有其他进程。

一个高效的算法是让每个进程同时将自己的消息沿着环传递:

  • 算法过程:在第一步,每个进程将自己的值发送给环上的下一个进程。在第二步,每个进程将上一步从上一个进程收到的值(不是自己的值)继续传递给下一个进程。如此重复 P-1 步。
  • 无冲突:由于每个步骤中,所有消息都在不同的链路上传输,因此可以并行执行,没有冲突。
  • 时间复杂度:总共有 P-1 步,每步发送一个大小为 M 的消息。总时间为:
    T = (Ts + M * Tw) * (P - 1)

总结 🎯

本节课我们一起学习了多种集体通信操作在不同网络拓扑上的高效实现算法:

  • 我们首先建立了通信成本模型 T = Ts + M * Tw
  • 环网上,广播和规约算法通过对数步(log₂(P))完成,利用了并行帮助机制。
  • 网格超立方体上,广播通过将问题分解为多个低维子问题并行解决,同样达到了对数级时间复杂度。
  • 对于全广播操作,在环网上可以通过让所有进程同时沿环传递消息,在 P-1 步内无冲突地完成。

理解这些基础算法的实现和成本分析,对于设计和优化并行程序至关重要。下一讲我们将进入共享内存编程,学习使用OpenMP语言。

008:OpenMP 第一部分

在本节课中,我们将学习如何在共享内存系统中编写并行程序,并重点介绍一种名为 OpenMP 的流行工具。

概述:共享内存架构

上一节我们介绍了分布式内存编程(如MPI)。本节中,我们来看看共享内存架构。

在共享内存架构中,多个处理器连接到一个公共的内存模块。这意味着存在一个单一的地址空间。当一个进程可以访问一个变量时,所有其他进程也都能看到这个变量。

这与MPI的分布式内存架构形成对比。在分布式内存中,内存被分割成不同的部分,不同进程只能看到自己的那部分内存。如果它们想访问其他部分的内存,则需要向相应的处理器发送消息。而在共享内存中,我们不需要发送任何消息,因为每个进程都能看到每一个内存位置。

共享内存编程的优势与挑战

共享内存编程的主要优势是它比分布式内存程序更方便、更容易编写。例如,在使用MPI时,每当你想获取一段数据,实际上需要在两个进程之间进行协调,因为发送方需要发送数据,接收方也需要接收数据。如果没有这种协调,进程就会阻塞,程序就无法工作。此外,程序员还需要跟踪数据的位置。

对于共享内存编程,情况则简单得多,因为所有数据对所有进程都是可访问的。

共享内存系统的另一个优势是,由于所有处理器都紧密连接,它们之间的带宽通常更高。因此,每个处理器通常能实现更高的性能。当然,大型分布式内存系统在总体性能上可能更强,但就单个处理器的性能而言,共享内存系统往往更高。

然而,共享内存编程的一个缺点是程序员需要小心不同类型的错误,特别是并发错误。我们今天会讨论一些,并在下一讲中更详细地探讨这些并发错误。

共享内存编程的实现方式

以下是编写共享内存程序的几种不同方式。

直接使用线程

第一种方式是直接使用线程,例如通过Pthreads库或Java编程语言。程序员将一个大型程序分解成多个小片段,每个片段是一个线程,这些线程可以并行执行并访问共享数据。

这种方式的优势在于它是一种非常通用的并行编程方式,基本上可以实现任何功能。然而,正因为其通用性,程序员需要负责很多事情,例如线程间的同步、线程的创建和销毁,这对程序员来说工作量很大。

使用特定的并行编程语言

第二种方式是使用专门为并行编程设计的语言。这是一个不错的选择,因为你可以很好地控制线程并获得良好的性能。但问题在于,例如,你需要为这种语言设计新的编译器。编写一门新语言需要大量工作,而且程序员也需要学习这门新语言,这也很困难。

使用编译器指令

第三种方式是使用编译器指令,例如OpenMP就使用编译器指令。事实上,这是目前最流行的选择。主要原因是这些编译器指令基本上只是在正常的顺序程序(比如用C或Fortran编写)中添加一些小注解。这些简单的注解告诉程序何时希望以并行方式执行某些操作。因此,程序员不需要学习太多新知识就能编写并行程序。

你还可以获得非常好的性能。而且,为使用编译器指令的程序设计编译器,比为全新的并行编程语言设计编译器要容易一些。

尽管程序员只使用这些简单的指令来控制并行性,但在底层,程序仍然使用线程运行。不同之处在于,线程是由运行时系统自动管理的,程序员无需担心。

这些编译器指令非常容易编写,但也会失去一些灵活性,不如直接使用线程那样通用。但在大多数情况下,你可以完成所需的任务。此外,对编译器的改动要求也更少。这是目前编写共享内存程序最流行的方式。

进程与线程的区别

在深入OpenMP之前,我们需要谈谈进程和线程的区别。

我们可以使用多个并行进程或多个并行线程来实现并行化。那么,进程和线程有什么区别呢?例如,MPI使用多个并行进程。每个进程都是一个独立的程序,这意味着它具有这里显示的所有组件:代码、栈和堆。堆是这个程序使用的内存。如果同时运行多个进程,它们无法访问彼此的堆。如果一个程序想要访问另一个程序的堆,就必须以某种方式将数据从一个程序的堆传输到另一个程序的堆。例如,在MPI中,这是通过发送和接收函数完成的。

而线程是OpenMP使用的。在OpenMP中,你没有多个并行进程,而是在一个进程内有多个并行线程。线程之间的区别在于,所有线程都在一个进程内运行,它们执行这段代码的不同部分。因此,线程可以做不同的事情,因为它们可能位于代码的不同部分。

线程也有自己的内存,即每个线程有自己的栈。栈用于跟踪每个线程的自动变量和函数调用。不同的线程可能调用不同的函数,因此它们有不同的栈。

然而,线程共享一些东西:由于它们在一个进程内运行,所有线程都可以访问该进程的堆内存。通过这种方式,不同的线程可以共享内存,因为它们都可以访问这个堆。如果我写入堆,你从堆中读取,我们就成功地进行了通信。

请记住,这是在操作系统中实现共享内存的方式。

OpenMP的线程模型:Fork-Join模型

现在我们来谈谈OpenMP的线程模型。OpenMP基于Fork-Join模型。

Fork-Join模型是这样工作的:我们从一个称为主线程的单个线程开始。它执行一系列工作,直到到达某个点,意识到有很多工作可以并行完成。在开始时,主线程可能执行一些顺序工作,例如读取文件或初始化数据结构。但在某个时刻,例如遇到一个大型计算时,它希望将工作并行化。

此时,它将创建一系列并行任务,这些任务可以分配给多个并行线程。任务数量可以多于或少于线程数量。最终,这些并行任务完成所需的工作,然后任务重新汇合,再次变成一个线程。这个线程继续执行,可能在稍后再次遇到需要并行化的地方,再次分叉成多个并行线程。这个过程不断重复:分叉成多个线程,当并行工作完成后,线程再汇合成单个线程。

主线程可以分叉出这些从属线程,从属线程本身也可以递归地创建更多线程。例如,主线程创建两个线程,然后这两个从属线程可以创建更多子线程。当这些子线程汇合时,它们汇合回其中一个从属线程,最终这两个从属线程再汇合回主线程。

每当创建一个线程,最终该线程完成并与其他分叉的线程汇合。但也可以创建分离的线程,它们独立执行,终止时不进行汇合。

这种Fork-Join模型实际上可以捕获我们在实践中关心的许多类型的并行性。例如,考虑归并排序算法。归并排序的工作原理是:我们试图对这里的所有数字进行排序,将一个数组分成两部分,然后递归地对每一部分进行排序,不断分割直到数组足够小,然后对那个小数组进行排序,最后将这些已排序的数组合并回来,最终得到完全排序的数组。

如果想在Fork-Join模型中运行这种算法,那么最初会有一个主线程。也许主线程从文件中读取这个数组,执行一些顺序工作。然后,当主线程想要开始对这些数字排序时,就涉及到了并行性。主线程可以分叉出另一个线程。例如,主线程继续在这里工作,然后它分叉出一个从属线程。

正如我们所说,从属线程可以递归地分叉出更多线程。例如,这里的从属线程可能因为这个数组对它来说太大而无法独自排序,它可以再次分叉另一个线程。从属线程取数组的一半,然后分叉一个线程处理另一半。同时,主线程也可以细分工作,例如取数组的一半,将另一半交给第三个从属线程。只要需要,就不断分叉线程。也许当数组大小减少到2时,从属线程可以自己排序,不再进行分叉。

最终,在从属线程排序完自己的子数组后,它们需要合并回来。例如,S2线程将汇合回S1线程,S3线程将汇合回主线程。然后,第一个从属线程汇合回主线程。之后,计算完成,主线程可以继续执行。

因此,Fork-Join模型实际上可以捕获许多不同类型的并行性。尽管它不是唯一的并行模型,但它是一个非常实用的模型。

共享内存程序执行的注意事项

接下来,我们将讨论执行共享内存程序时需要注意的一些事项。

第一件事是,我们需要思考这些线程实际上是如何在物理上协同工作的。如果只有一个线程和一个程序,那么程序只是一系列代码行,单个线程将按顺序逐行执行这些代码。当然,有时会有循环,但基本上它是严格按照程序编写的顺序执行指令。

当你有多个线程时,情况就不同了。例如,这里有两个线程,每个线程都有自己想要执行的一系列指令。但当同时执行这两个线程时,物理上发生的是这两个指令流需要以某种方式交错执行。例如,这个指令可能发生在时间 t=1 秒,另一个指令发生在 t=1.5 秒,下一个发生在 t=3 秒,再下一个发生在 t=3.5 秒。那么物理上发生的是:这个指令先执行,然后是那个(在1.5秒),接着是这个,最后是那个。

当然,这些指令发生的顺序并不固定,它们实际上可以以任何顺序交错。例如,也许这个指令实际上发生在3秒,而那个指令发生在2秒。那样的话,我们会先执行这个,然后那个,接着这个,最后那个。

无论如何,当同时执行这两个线程时,会发生某种实际的交错。这是一种可能的交错,还有许多其他可能的交错。

现在,线程执行相互交错的事实实际上可能导致许多问题。因为有时如果你编写的程序不正确,那么根据发生的交错,程序可能实际上存在错误。可能程序在某些交错下工作正常,但如果切换到不同的交错,程序行为就会不正确。

如果存在这种情况,那么我们说这个程序存在竞态条件,因为程序的正确性取决于发生的交错。

由于我们作为程序员通常无法控制实际会发生哪种交错,如果程序在某些交错下行为异常,那么该程序就是不正确的,我们不能使用它。因为如果你把它交给用户,而恰好遇到了那些不好的交错,你的程序就会做出不正确的事情。

因此,编写在所有交错下都能正常工作的正确并发程序实际上相当具有挑战性,事实上,这是编写共享内存程序的主要挑战之一。

竞态条件示例

让我们看看实际上会发生什么样的错误,什么样的竞态条件会发生。这里给出一个简单的例子。

通常,这些错误发生在不同线程操作共享内存时。如果它们都访问完全不同的内存区域,并且线程之间不交互,那么一个线程基本上无法干扰另一个线程。只要各个线程工作正确,如果它们不相互交互,就不会发生坏事。只有当它们通过共享数据交互时,坏事才会发生。

现在,我们的例子是:假设有两个线程正在做一件非常简单的事情,它们只是获取一个值然后递增它。两个线程共享一个值 x,它们都想递增这个值。例如,如果值从0开始,那么如果两个线程都递增,x 最终应该变为2。

但是,让我们看看如果同时运行这两个线程会发生什么。问题在于,当你同时运行这两个线程时,即使我们只想做递增变量这一件事,这实际上涉及多个子步骤。递增并不是原子发生的,我们需要做几个步骤。

第一步是,变量存储在内存中的某个位置。为了递增它,我们首先需要将变量从内存读入线程使用的寄存器中(实际上是处理器使用的寄存器)。线程1和线程2各自有一些寄存器用于计算。线程1首先将 x 从内存加载到它的寄存器中,我们称之为 R1。假设 x 初始等于0。

接下来,我们将在寄存器中递增 x,然后将 x 的值从寄存器写回内存。线程2将做同样的事情:它也将 x 从内存加载到线程2的寄存器中,在自己的寄存器中递增它,然后写回内存。

由于每个线程都在执行多个指令,它们可以以某种任意顺序交错。假设右边是交错顺序。那么,如果发生这种交错,最终 x 的值会是多少呢?

在这种交错下,首先执行的是线程1(用黑色操作表示),线程2用蓝色操作表示。

线程1首先将 x 加载到它的寄存器 R1 中。如果 x 初始为0,那么现在 R1 等于0。然后它递增 x,现在 R1 将等于1(它在自己的寄存器中递增 x)。所以现在 R1 = 1。

假设此时,线程2执行其第一个操作。线程2将 x 加载到它的寄存器 R2 中。此时,内存中的 x 仍然是0,因为线程1所做的只是在它的寄存器中递增 x 的值,内存中的 x 仍然是0。因此,当线程2加载 x 时,它会将其寄存器设置为0。

接下来,假设我们回到线程1,它将把数据从 R1 存储回内存,所以现在 x = 1。

然后我们回到线程2,它将在其寄存器中递增 x 的值,所以现在 R2 = 1。然后它将 R2 的值写回内存,所以最终 x 将等于1,因为线程2写回了值1。

我们看到,在所有这些操作之后,x 的值是1。但这是不正确的,因为直观上我们希望的是,既然有两个线程执行了两次递增,我们希望 x 的最终值等于2。

这是一个竞态条件的例子,程序在某些交错下行为不正确。

线程安全与临界区

那么我们如何处理这种情况呢?如果我们使用一个包含一些预写例程的库,我们说一个例程是线程安全的,如果它可以同时从多个线程调用而不产生任何错误。基本上,这个例程不应该有任何竞态条件。

如果没有竞态条件,它就是一个线程安全的例程。例如,许多I/O例程被编写成线程安全的。例如,如果你从两个不同的线程进行打印操作,如果打印操作不是线程安全的,那么打印出来的消息可能会混乱、混合在一起。但如果打印操作是线程安全的,你将看到每个线程打印出完整的消息,不同的输出不会相互混淆。

但有些例程你必须小心,它们没有被写成线程安全的。这些例程一次只能被一个线程正确使用。例如,有些随机数生成器只有在一次只被一个线程使用时才能产生真正好的随机数。如果你有几个线程同时调用随机数生成器,你可能会得到并不真正随机的输出。

正如我所说,通常这些竞态条件只会在我们访问共享变量时发生。每当我们有多个线程访问共享数据结构时,我们必须非常小心如何编写这些程序。

编写一个即使在多线程下也能正确工作的程序,一种方法是使用称为临界区的东西。还有其他方法,我将在后面的讲座中讨论,但今天我们将看看如何使用临界区来确保程序的正确性。

临界区是一段一次只能由一个线程执行的代码。因为这段代码只由一个线程执行,所以只要代码在临界区内,该线程就可以对数据进行多次更改,而不会被任何其他线程中断。在临界区内,只有一个线程可以执行该代码,其他线程在此期间都不能接触该代码。

这也称为互斥,因为不同的线程相互排斥。有许多使用临界区的方法,例如,Java有同步语句来实现临界区,许多其他编程语言和操作系统也为程序员提供了使用临界区的方法。

临界区帮助你避免我们看到的竞态条件错误。例如,我们试图递增一个值,这由几个操作组成。如果我们将所有这些操作放入一个临界区,那就意味着如果一个线程正在执行这些操作,它不会被任何其他线程中断。线程要么执行所有三个操作,要么一个都不执行。不会出现线程执行了两个操作然后突然被其他线程中断的情况。

现在,如果这段代码确实在一个临界区内,那么当你同时运行这两个线程时,可能会得到什么样的交错呢?例如,你可能得到这种类型的交错:线程2执行完整个代码块,然后线程1执行整个代码块。

在这种情况下,我们实际上得到了正确的 x 最终值,因为这里你加载 x,递增到1,然后存储1。然后线程1将读取 x 为1,递增它,然后将2存储回 x。即使线程1先执行,只要所有三个操作一起发生,不被其他线程中断,程序就会正确工作。

因此,我们通过在临界区中放置某些代码块来确保并发程序的正确性。这样,如果你在开始临界区之前程序处于正确状态,那么由于你可以自己执行整个临界区,之后程序会转换到另一个正确状态。程序始终保持在正确状态。

锁与互斥

锁是确保互斥的一种方式。锁的工作原理是:在线程进入临界区之前,它设置锁,然后在离开临界区后解锁。这些锁具有某些属性:如果锁是打开的,线程可以设置锁,然后锁变为锁定状态。但如果线程试图使用锁而锁已经被锁定,那么线程将无法做任何事情,它会阻塞,必须等待锁被解锁。

因此,如果你有一个保护临界区的锁,那么只有第一个成功设置锁的线程可以进入并执行临界区。第二个及之后到来的线程会发现锁已经被锁定,因此它们需要在锁处阻塞等待,无法执行临界区内的代码。这样,一次只有一个线程在执行临界区。

第一个到来的成功设置锁的线程最终完成临界区内的所有操作后,会解锁。此时,所有等待的线程可以相互竞争,再次尝试设置锁。其中一个竞争线程将成功设置锁,然后该线程可以执行临界区。其他未能设置锁的线程将再次阻塞,在锁处等待。

这就像你可能会有的代码片段:在执行临界区之前,你想确保一次只有一个线程执行这里,所以多个线程会来到这个锁,它们都尝试锁定,但只有一个会成功并进入临界区。其他线程将在这里被阻塞。最终,这个线程完成并解锁。一旦锁被解锁,这两个等待的线程可以再次竞争获取锁,假设第三个线程获取了锁,然后它可以进入临界区,但线程2再次被阻塞。

这样,我们始终一次只有一个线程在临界区内。

死锁

接下来我们谈谈使用锁时需要注意的问题。这是确保程序正确性的一种方式,但你必须小心如何使用锁,因为如果使用不当,系统可能会发生死锁。那样所有线程都会被阻塞。当然,你不希望这样。锁的目的是确保一个线程能够取得进展,而其他线程被阻塞。但当发生死锁时,所有线程都被阻塞,你的程序将永远卡住。

那么,什么情况会导致死锁呢?一个例子是线程需要多个资源。假设这里有两个线程 T1 和 T2,它们都想锁定两个资源。

如果 T1 先锁定 R1,然后 T2 锁定 R2,那么因为 T1 需要两个资源,T1 会尝试锁定 R2,但 T1 无法获取 R2,因为 T2 已经锁定了 R2,因此 T1 将阻塞。同样,T2 在获取 R2 后,也想获取 R1,但 T2 永远无法锁定 R1,因为 T1 已经锁定了 R1,因此 T2 也将阻塞等待。

现在你看到,T1 和 T2 最终都将永远等待,因为它们各自持有了一个所需的资源,但无法获取另一个,所以它们只能一直等待另一个资源,而另一个资源永远不会变得可用。

这是一个两个线程因等待公共资源而形成循环的例子。实际上,你可能会有超过两个线程形成循环的例子。例如,这里的交通情况也是死锁的一个例子,因为没有一辆车可以移动,整个十字路口被阻塞。基本上,你可以从线程和资源的角度来思考这个问题。例如,你可以把这些道路看作资源。要穿过这条道路,你可能需要两个资源。如果你想朝这个方向前进,也需要两个资源。如果你从那个方向来,也需要两个资源。如果你从上面来,也需要两个资源。现在,你可以把这四个行驶方向看作四个线程。每个线程都获得了一个资源,但需要两个资源,而它永远无法获得第二个资源,因为第二个资源已经被别人占用了。所以这些线程就卡在这里了。

如果你遇到这种卡住的情况,基本上唯一能做的就是让某些线程回退,基本上就是重置系统。例如,这里有些车需要倒车,如果它们倒车,就会清理出这条车道,然后这些车可以走,其他车道的车也可以走。但如果你遇到死锁,就需要以某种方式重置系统。

当然,最好从一开始就避免死锁。避免死锁的一种方法是,如果你有这两个资源,这里的问题是 T1 先锁定了 R1,然后 T2 锁定了 R2,之后 T2 试图获取 R1,但那时已经太晚了,因为 R1 已经被 T1 占用了。相反,避免死锁的一种方法是让所有线程以相同的顺序锁定资源。所以,T1 和 T2 都应该先尝试锁定 R1,而不是 T2 先锁定 R2。

如果它们都先尝试锁定 R1,那么会发生什么?T2 不会尝试锁定 R2,它会尝试锁定 R1。由于 T1 和 T2 都试图锁定 R1,只有一个会成功,另一个将等待。

假设 T1 成功了,那么 T1 获取了 R1,现在 T1 可以去获取 R2,因为 R2 还没有被任何人占用。然后 T1 可以获取 R2,一旦它获得了两个资源,它就可以执行其操作。

最终,T1 完成其操作,然后它将释放 R1 和 R2。此时,T2 可以获取 R1,然后也可以获取 R2,这样 T2 就可以继续执行。

如果我们确保线程以相同的顺序锁定资源,那么这些问题就不会发生。但在实践中,确保线程以相同顺序锁定资源并不那么容易,因为例如,一个线程可能一开始并不知道它需要哪些资源。也许最初 T1 知道它需要 R1,T2 知道它需要 R2,但一开始 T1 不知道它需要 R2,T2 不知道它需要 R1。那样的话,T1 将锁定 R1,T2 将锁定 R2,但如果后来它们试图锁定另一个资源,又会卡住。

因此,以相同顺序锁定资源并不总能解决问题,因为你甚至可能不知道需要哪些资源,所以无法以正确的顺序获取它们。

非阻塞锁原语

因此,如果我们不能总是以相同的顺序锁定资源来避免死锁,那么我们可以做的另一件事是使用非阻塞锁原语。基本上,这些原语或函数尝试获取锁,但如果获取锁失败,它们不会阻塞线程,线程仍然可以做其他事情。

其工作原理是:假设你有一个名为 test_lock 的非阻塞函数。我们尝试获取这个互斥锁。如果我们获取成功,即标志 flag 为0,那么我们实际上成功获取了锁,然后我们可以进入临界区并执行所有操作。完成临界区后,我们可以解锁。

然而,如果锁已经被别人占用,那么标志 flag 将为1。在这种情况下,我们将进入 else 部分,但线程不会在这个函数处阻塞,它会跳转到 else 部分,并可以在下面做更多事情。

这意味着线程不会卡在试图获取锁上。如果线程想要获取锁,它调用了 test_lock 但获取失败,跳转到 else 部分,那么稍后线程可以回到这个 test_lock 并再次尝试获取锁。也许到那时,在临界区内的线程已经完成并释放了锁,现在这个线程可以实际获取锁了。线程只是不断回来尝试获取锁,如果失败,那也不是大问题,线程不会卡住,它会去做其他事情。

使用临界区的性能影响

接下来,我们将讨论使用临界区的性能影响。有时你必须使用临界区来确保代码正确工作,安全第一,我们需要确保代码安全且正确工作。然而,你不想过度使用临界区,因为如果你使用很多临界区,那么你的代码确实能正确工作,但性能可能非常差。原因是,当你使用临界区时,你实际上是在串行化你的代码。因为如果你这里有一个临界区,那么一次只能有一个线程可以运行这个临界区。如果你有几个线程都需要通过这个临界区,那么它们必须一个接一个地顺序通过临界区。你看,在临界区内没有任何并行性。

因此,如果你创建一个非常大的临界区,那么很容易确保代码正确,因为所有东西都在临界区内。但也正因为所有东西都在临界区内,你就没有任何并行性。所以,你确实需要使用临界区,但你想让它们尽可能小,以确保程序正确。只要程序正确,你就不想让它们变得更大。

条件变量

接下来我们谈谈条件变量。这些变量与临界区一起使用。其思想是,有时只有在特定条件发生时,才应该执行临界区。我稍后会给你一个例子,但在这些情况下,我们想使用条件变量。

其工作原理是:线程将获取临界区的锁,但它应该只在条件发生时执行该临界区。在线程获取了临界区的锁之后,它将调用条件变量。如果条件为真,那么线程可以进入临界区。但如果条件为假,那么线程应该等待,直到条件变为真。

当线程等待时,你不想让线程持有锁,因为如果它持有锁,就会阻塞所有其他线程可能使用临界区。所以当线程等待时,它实际上会进入睡眠状态,然后释放锁。它原子性地完成这两件事。进入睡眠状态意味着线程将停止不做任何事情,因为它在等待条件变为真,在条件变为真之前它无事可做。

事实上,你可能有几个线程都在等待这个条件。那样的话,我们可以把所有这些线程放入一个队列,以某种方式跟踪所有这些等待的线程。

最终,假设这个条件变为真。那时,将发生一个信号。这个信号将告诉所有等待的线程,现在条件已经变为真。既然条件成立,所有线程都将唤醒。唤醒后,它们尝试重新获取锁。当然,由于要竞争锁,只有一个线程能成功。

我们使用条件变量的原因是,这比不断测试锁以查看条件是否满足更高效。如果条件当前为假,那么我不如直接进入睡眠状态,等待条件变为真,而不是一遍又一遍地测试这个条件是否为真。

要使用这些条件变量,我们有几个函数。我们有 wait 函数,由当前持有锁的线程调用,它想检查这个条件是否为真。如果为假,它将进入睡眠。signal 函数用于发出条件已变为真的信号。一旦信号发生,所有等待该条件的线程都会唤醒,然后它们尝试重新获取锁。信号可能只唤醒一个线程,但我们实际上可以使用 signal_all 唤醒多个线程或所有线程。

条件变量示例:生产者-消费者问题

让我们看一个使用条件变量的例子:生产者-消费者问题。

这里的情况是:我们有一批消费者线程和一批生产者线程,它们都访问这个队列。生产者线程将物品放入队列,消费者线程从队列中移除物品,它们不断这样做。

然而,如果队列当前为空,那么消费者线程没有东西可移除,因此消费者线程必须等待。如果你是消费者线程并尝试取东西,条件是队列必须非空。如果队列为空,你必须等待。另一方面,这个队列有容量,假设你最多可以放100件物品到队列中。如果队列已满,已经有100件物品,那么生产者必须等待。如果你是生产者线程,那么你放入物品的条件是队列未满。如果当前已满,你必须等待直到队列再次未满。

解决这个问题的一种方法是,生产者和消费者在访问队列时,因为队列在多个线程间共享,线程需要锁定队列。但如果你锁定队列,为了解决这个生产者-消费者问题,你可以锁定队列,检查它是否为空或满。如果你是生产者,检查队列是否未满;如果你是消费者,检查队列是否非空。但如果你的条件不满足,那么你可以解锁队列,但稍后你必须回来重新检查条件。实际上,你必须一遍又一遍地检查这个条件,每次检查时都必须再次尝试获取锁。获取锁、检查条件、然后释放锁,再重复这个过程,实际上效率非常低。

更高效的做法如下:这里是生产者线程和消费者线程的代码。我们简要过一遍。

生产者线程试图将物品放入队列。因为这是一个共享队列,它需要先锁定队列以获取访问权限。它设置锁,然后检查队列中的物品数量是否等于 n(即最大容量)。如果小于 n,队列未满,那么生产者可以增加物品数量,然后它会向所有消费者线程发送一个信号,因为它向队列中放入了一个物品,所以队列非空。之后它完成操作,释放锁。

另一方面,如果生产者在尝试放入物品时,队列已满(物品数等于 n),那么生产者需要等待才能放入物品。它需要等待队列未满的信号。当它等待时,当前生产者持有队列的锁,其他线程都无法访问队列。如果生产者保持对队列的锁,因为队列现在已满,并且由于其他线程无法访问队列,那么永远没有人能从队列中取出东西,队列将永远满着。这意味着生产者将永远等待在那里。因此,生产者必须放弃对队列的锁,以便其他线程可以从队列中取出东西。只有在其他线程从队列中取出东西后,这个生产者才能将其物品放入队列。这就是为什么生产者在等待“未满”信号时,必须释放锁。

消费者的行为与此相反。消费者要访问锁,它试图从队列中取出东西。为此,它必须先锁定队列,然后检查队列是否为空。如果物品数等于0,队列为空。如果物品数不为0(大于0),那么消费者可以到这里,取出一个物品,减少物品数量。既然它取出了一个物品,它可以发送一个信号,表明队列不再满。例如,记住这些生产者线程正在等待信号发生。这个消费者线程很好,它从队列中取出了一个物品,所以现在我们知道队列不再满了,消费者将向任何可能正在等待的生产者发出情况信号。然后消费者完成并释放锁。

然而,如果这个消费者在尝试取出物品时,队列已经为空,那么消费者需要等待直到队列变为非空。同样,消费者也需要放弃锁,因为如果消费者此时持有锁,那么队列将永远为空,因为没有人可以访问队列来放入东西,那么消费者将永远等待在这里。所以消费者需要释放锁,然后等待“非空”信号。

现在让我们看一个可能的执行过程。假设这里有一个队列,当前为空。那么有一个消费者线程在队列处等待,它等待队列变为非空,但当前为空,所以这个消费者线程需要等待。

这个消费者线程的执行方式是:它先设置锁,检查队列是否为空,是空的,所以它放弃锁。现在当消费者1等待时,锁实际上是空闲的。因为锁空闲,可能有另一个消费者过来,它也设置锁,也检查是否为空,是空的,所以消费者2也开始等待,当它等待时,它会释放锁。

后来,可能有一个生产者线程过来,它会尝试设置锁。锁当前是空闲的,因为C1和C2都释放了锁,所以P1可以获取锁。它可以放入一个物品。生产者过来,设置锁,检查是否已满,未满,因为队列中什么都没有。然后它到这里,添加一个物品,然后发送信号。这个信号将提醒这两个线程。

当前C1和C2正在睡眠。一旦P1发出这个信号,这些线程将唤醒。当它们唤醒时,它们尝试重新获取这个锁。当前P1持有锁,所以C1和C2还无法获取锁。但在P1发送信号后,它将释放锁。现在锁空闲了,假设P1之后离开。现在C1和C2都试图获取锁。假设C1获取了锁,那么它获得了锁,而C2因为没获取到锁,将被阻塞。

C1获取锁后,将执行到这里。它在while循环中。获取锁后,它将返回到while循环的开头,再次检查物品数是否等于0。当前队列中有一个物品,所以物品数等于1,因此C1可以到这里,它可以减少物品数。然后它将发送它的“未满”信号,然后释放锁。

在它释放锁后,记住C2在这里仍然被阻塞,试图获取锁。因为在C1和C2唤醒后,它们都试图获取锁。C1获取了锁,但C2被阻塞了。现在C2设法获取了锁,因为C1释放了它。

但请注意,C2仍然在它的代码中的这个位置。即使它获取了锁,因为在代码的这个位置,在继续之前,它需要返回到while语句的开头,所以它需要再次检查物品数是否等于0。事实上,现在物品数又等于0了,因为C1取出了唯一的物品。因为物品数为0,不幸的是,C2将不得不再次进入睡眠,它必须等待直到再次发生“非空”信号。当它进入睡眠时,它将放弃它的锁。

顺便说一下,这个例子向你展示了为什么这里需要一个while语句。如果你编写这段代码,你可能认为只需要一个if语句在这里检查物品数是否等于0,但这不够。因为如果你这里只有一个if语句,那么在这些等待的线程唤醒后,它们不会重新检查物品数是否等于0。在C1和C2唤醒后,它们都只会尝试取一个物品,这将是不正确的,因为只有C1应该能取出物品,C2需要再次等待。

这就是如何使用条件变量的例子。

总结

本节课中,我们一起学习了共享内存编程的基础概念,特别是OpenMP的实现方式。我们探讨了进程与线程的区别,深入了解了OpenMP基于的Fork-Join线程模型。我们分析了共享内存程序中可能出现的竞态条件及其危害,并学习了如何使用临界区和锁来确保程序的正确性。我们还讨论了死锁问题及其避免方法,以及条件变量在生产者-消费者等同步问题中的应用。这些概念是理解和使用OpenMP进行高效并行编程的基础。在接下来的课程中,我们将具体学习如何使用OpenMP指令来编写实际的并行程序。

009:OpenMP 第二部分 🚀

在本节课中,我们将深入学习 OpenMP 编程模型的核心概念,包括并行区域、工作共享、变量共享与私有化、同步机制,并通过曼德博集合的计算实例来展示如何应用这些概念解决实际问题。

概述

OpenMP 是一个基于共享内存的并行编程标准,它通过编译器指令(Compiler Directives)来指导编译器生成并行代码。其核心思想是“增量并行化”,允许程序员在现有串行代码的基础上逐步添加并行指令,从而平滑地过渡到并行程序。OpenMP 采用“Fork-Join”执行模型,由主线程在并行区域开始时创建一组工作线程,在区域结束时同步并合并。


并行区域与线程创建

上一节我们介绍了 OpenMP 的基本概念,本节中我们来看看如何创建并行区域。

OpenMP 程序始于一个主线程。要创建并行区域,需要使用 #pragma omp parallel 指令。该指令会创建一个线程团队,团队中的所有线程将共同执行紧随其后的代码块(用花括号 {} 括起)。在并行区域结束时,所有线程会进行同步(Join),程序恢复为仅由主线程执行。

线程的数量默认由系统决定,但程序员可以通过多种方式控制:

  • 使用 num_threads 子句:#pragma omp parallel num_threads(4)
  • 调用函数:omp_set_num_threads(4)
  • 设置环境变量:OMP_NUM_THREADS=4

通常建议每个处理器核心使用一个线程,但对于轻量级任务,也可以设置多个线程。

以下是一个简单的“Hello World”示例,展示了并行区域的基本用法:

#include <omp.h>
#include <stdio.h>

int main() {
    #pragma omp parallel private(iam, np)
    {
        int np = omp_get_num_threads(); // 获取总线程数
        int iam = omp_get_thread_num(); // 获取当前线程ID
        printf("Hello from thread %d out of %d\n", iam, np);
    }
    return 0;
}

在这个例子中,iamnp 被声明为 private,意味着每个线程都拥有自己独立的副本。所有线程都会执行打印语句,但由于线程执行速度不同,输出顺序是任意的。


工作共享构造

在并行区域内,我们通常不希望所有线程执行完全相同的任务,而是希望将工作划分给不同的线程。OpenMP 提供了多种工作共享构造来实现这一点。

并行循环

最常见的场景是并行化一个 for 循环。可以使用 #pragma omp for 指令将循环的迭代划分给线程团队中的各个线程。注意#pragma omp for 本身并不创建线程,它必须位于一个由 #pragma omp parallel 创建的并行区域内。

为了方便,OpenMP 提供了 #pragma omp parallel for 指令,它将创建并行区域和划分循环迭代两个步骤合并。

#pragma omp parallel for
for (int i = 0; i < n; i++) {
    a[i] = b[i] + c[i];
}

默认情况下,在并行 for 循环的末尾存在一个隐式屏障(Barrier),所有线程必须在此处等待其他线程完成其迭代后才能继续执行后续代码。可以使用 nowait 子句移除这个屏障。

调度策略

schedule 子句用于控制如何将循环迭代分配给线程。以下是几种主要的调度策略:

  • 静态调度schedule(static [, chunk])
    • 在循环开始前就将迭代块分配给线程。
    • 若不指定 chunk,则迭代被尽可能均匀地分配给每个线程。
    • 若指定 chunk,则按轮询(Round-Robin)方式,每次分配给线程 chunk 个迭代。
  • 动态调度schedule(dynamic [, chunk])
    • 迭代在运行时动态分配。线程完成当前分配的迭代后,会请求并获得下一批迭代(默认为1个,或由 chunk 指定)。
    • 适用于迭代计算量不均衡的情况,能实现更好的负载均衡,但存在调度开销。
  • 引导调度schedule(guided [, chunk])
    • 类似于动态调度,但每次分配的迭代块大小会逐渐减小。开始时分配大块,后期分配小块。
    • 能在负载均衡和调度开销之间取得折衷。

选择调度策略时,需权衡负载均衡和调度开销。对于计算量均匀的循环,静态调度是高效的选择;对于计算量差异大的循环,动态或引导调度可能更优。

其他工作共享构造

除了循环,OpenMP 还支持其他工作共享方式:

  • Sections#pragma omp sections 可以将不同的、独立的代码块(Section)分配给不同的线程执行。
  • Single#pragma omp single 指定紧随其后的代码块仅由一个线程(不一定是主线程)执行一次。
  • Master#pragma omp master 指定紧随其后的代码块仅由主线程执行,且此处没有隐式屏障。

数据共享与私有化

在共享内存环境中,线程可以访问相同的变量。OpenMP 通过数据共享属性来控制变量的可见性。

  • 共享变量:默认情况下,全局变量、静态变量以及在并行区域外声明的变量是共享的。
  • 私有变量:默认情况下,循环索引变量、函数内的局部(栈)变量以及并行区域内声明的自动变量是私有的。

程序员可以使用 sharedprivate 子句显式控制变量的共享属性:

int x = 5;
#pragma omp parallel private(x)
{
    int tid = omp_get_thread_num();
    x = tid; // 每个线程修改自己的私有副本x
    printf("Thread %d: x = %d\n", tid, x);
}
printf("After parallel region: x = %d\n", x); // 输出 5

此外,还有两个有用的子句:

  • firstprivate:私有变量在进入并行区域时,使用并行区域外该变量的值进行初始化。
  • lastprivate:在并行区域结束时,将最后一次循环迭代(对于循环)或某个线程(对于 sections)中私有变量的值,赋给并行区域外的共享变量。

归约操作

归约(Reduction)是一种常见且重要的并行模式,例如求和、求最大值等。如果简单地使用临界区(Critical Section)保护共享变量,性能会非常差。

OpenMP 提供了 reduction 子句来高效地实现归约操作:

int sum = 0;
#pragma omp parallel for reduction(+:sum)
for (int i = 0; i < n; i++) {
    sum += a[i]; // 每个线程操作自己的私有sum副本
}
// 循环结束后,所有线程的私有sum副本通过加法归约,结果存入共享变量sum

其工作原理是:为每个线程创建归约变量的私有副本。每个线程计算自己那部分数据的局部结果。在并行区域结束时,将所有线程的局部结果按照指定的操作符(如 +, *, max, min 等)合并,得到最终结果。这种方式避免了使用临界区带来的巨大开销。


同步机制

当多个线程访问共享资源时,需要同步机制来避免竞态条件(Race Condition)。

  • 临界区#pragma omp critical 确保其后的代码块在同一时刻只能被一个线程执行。可以为临界区命名以保护不同的资源。
  • 原子操作#pragma omp atomic 用于保护简单的内存更新操作(如 x++, x = max(x, y))。原子操作通常由硬件直接支持,速度远快于临界区。
  • :OpenMP 提供了一组锁函数(omp_init_lock, omp_set_lock, omp_unset_lock, omp_test_lock, omp_destroy_lock),提供了更细粒度的数据保护能力。锁保护的是数据,而临界区保护的是代码段。
  • 屏障#pragma omp barrier 强制所有线程在此点同步,确保所有线程都执行到此位置后才能继续。
  • Flush#pragma omp flush (list) 确保指定变量的修改对所有线程可见。它强制将线程缓存中的变量值写回内存,并使其对其他处理器核心可见。这对于弱内存一致性模型很重要。


实战案例:计算曼德博集合

曼德博集合是复平面上的一组分形点集,其定义基于一个简单的迭代公式:对于复常数 c,从 z = 0 开始,反复计算 z = z*z + c。如果迭代无限次后 z 的模长不发散(有界),则 c 属于曼德博集合。

在实际计算中,我们设定一个最大迭代次数(如256)。对于图像中的每个像素(对应一个复数值 c),进行迭代。如果在达到最大迭代次数前 z 的模长超过2(发散阈值),则停止迭代,并根据发散速度赋予颜色;如果迭代至最大次数模长仍小于2,则视为属于集合,通常涂为黑色。

计算每个像素的过程是完全独立的,因此非常适合并行化。我们可以将图像的行或像素分配给不同线程。

负载均衡问题:曼德博集合的计算量分布极不均匀。集合边界附近的点需要大量迭代才能判断是否发散,而远离集合的点很快就能判断为发散。如果使用默认的静态调度(将连续的行块分配给线程),会导致线程间负载严重不均衡。

解决方案:使用能实现更好负载均衡的调度策略。

  • 静态调度(小块)schedule(static, 1) 以轮询方式每次分配一行,使每个线程都能混合处理计算量“轻”和“重”的行。
  • 动态调度schedule(dynamic) 让先完成工作的线程动态获取新任务,能更好地适应不可预测的计算负载。

通过选择合适的调度策略,可以显著提高并行效率。


其他共享内存编程模型

除了 OpenMP,还有其他共享内存编程模型值得了解:

  • PGAS 语言:分区全局地址空间语言(如 Unified Parallel C, Coarray Fortran)将全局地址空间进行逻辑分区,使线程能感知数据的“远近”(本地访问快,远程访问慢),旨在更好地利用数据局部性。它们目前多处于研究阶段。
  • MPI + OpenMP 混合编程:在现代集群中,节点内是共享内存(适合 OpenMP),节点间是分布式内存(适合 MPI)。混合编程模型结合两者优势,在节点内使用 OpenMP 进行细粒度并行,在节点间使用 MPI 进行通信,是解决大规模科学计算问题的常用方法。新版本的 MPI 标准也加强了对线程的支持。

总结

本节课我们一起深入学习了 OpenMP 并行编程。我们从创建并行区域和线程团队开始,探讨了如何通过工作共享构造(如 parallel for, sections)将任务分配给线程。我们理解了数据共享与私有化的规则,并掌握了使用 reduction 子句高效处理归约操作的方法。此外,我们还学习了临界区、原子操作、锁和屏障等同步机制,以确保多线程程序的正确性。最后,通过曼德博集合的计算实例,我们看到了如何将 OpenMP 应用于实际问题,并体会到负载均衡和调度策略对性能的关键影响。OpenMP 以其增量并行化和相对简单的编程模型,成为共享内存并行编程的重要工具。

010:共享内存同步 🧵

在本节课中,我们将要学习共享内存编程中的同步问题。我们将探讨当多个并发线程访问共享数据时可能出现的错误,并学习如何使用不同的同步机制来避免这些错误,特别是互斥锁的实现。

概述

在共享内存编程中,多个线程可以同时访问和修改相同的内存位置。如果没有适当的同步机制,线程执行的交错顺序可能导致程序出现难以预测和复现的错误,即并发错误。本节课我们将深入探讨这些错误,并学习如何使用互斥锁、事务内存等机制来确保程序的正确性。

并发错误

上一节我们介绍了共享内存的基本概念,本节中我们来看看并发错误的具体表现和成因。

当多个并发线程访问共享数据且未进行任何同步时,就可能出现错误。例如,两个线程试图递增同一个值,但由于线程执行的交错顺序,该值可能只被递增了一次,而不是预期的两次。

这些错误之所以发生,是因为多个并发线程的执行可以以任意顺序交错进行。运行时环境控制着线程的执行速度,因此我们无法预测确切的交错顺序。有时,某些交错顺序会导致代码崩溃。

消除并发错误之所以困难,原因如下:

  • 测试的局限性:我们通常通过大量测试来消除错误。但对于并发错误,由于它们依赖于执行交错,因此是随机出现的。代码可能运行多次都正常,但某一次特定的交错就会触发错误。在开发测试阶段,可能永远无法发现某些罕见的错误。
  • 难以预测:这些错误是多个线程交互的结果。人们很难追踪和理解众多事物之间所有可能的交互方式,因为交互方式的数量是指数级的。人们通常不习惯,甚至无法思考如此复杂的交互。

并发错误并非只是理论问题,它们在现实中已造成严重后果。例如,Therac-25放射治疗设备因并发错误导致过量辐射,造成多名患者死亡。又如,美国航天飞机首次发射前,在其航空电子软件中发现了并发错误,导致发射被迫中止。

如今,随着多线程和多处理器程序的普及,并发错误将越来越常见。为了性能,我们需要编写并行程序,但这些程序更容易遇到并发错误,因此我们必须找到应对方法。

处理并发错误的方法

上一节我们了解了并发错误的危害,本节中我们来看看几种主要的应对策略。

以下是处理并发错误的几种主要方法:

1. 临界区与锁

第一种方法是使用临界区或锁。我们上次课讨论过这个概念。

临界区和锁的核心思想是,只允许一个线程在某一时刻访问某个资源(一段代码或一个数据)。更通用的术语是互斥,即多个线程相互排斥,同一时间只能有一个线程执行特定操作。

临界区和锁能有效解决并发错误。如果你怀疑代码中的某个操作序列在多个线程并发执行时会出现问题,只需在该代码块周围加上临界区,在大多数情况下就能消除问题。它们也易于使用,只需几个关键字即可定义临界区。

然而,不应过度使用临界区和锁,因为它们也会带来问题:

  • 线程争用:如果在一个代码块上设置了临界区,多个线程运行时需要竞争访问权。未获得访问权的线程必须等待,这会使代码从并行变为串行,导致性能下降。过多的临界区可能导致性能大幅损失。
  • 死锁:当多个进程试图获取多个资源的锁时,如果方式不当,可能导致死锁,即所有进程永远卡住。
  • 优先级反转:低优先级线程可能先于高优先级线程获得锁,导致高优先级线程被迫等待,这与我们的期望相反。

尽管存在这些问题,但由于人们已认识到这些问题并找到了各种解决方法(效果不一),锁至今仍是并发编程中最流行的方式。因此,本节课我们将更深入地研究锁,特别是如何高效地实现锁。

2. 事务内存

另一种消除错误的方法是事务内存

其基本思想是,将一段代码定义为一个事务。这意味着这段代码要么原子性地全部执行,并且在执行过程中不受任何其他线程的干扰;要么完全不执行(“要么全做,要么全不做”)。如果执行,则保证在执行期间没有其他线程干扰。

事务内存系统会跟踪事务中的所有读写操作。线程之间产生干扰的唯一途径是通过读写操作修改系统状态。如果多个并发线程都读写同一个内存位置,就可能产生错误。此时,事务内存系统将中止除一个之外的所有事务,只允许其中一个访问该公共内存位置的事务继续执行。当中止一个线程时,必须撤销它所做的所有更改。

如果两个并发事务访问完全不同的内存位置,则它们不可能相互造成问题,也不会产生并发错误。在这种情况下,两个事务都可以提交。

事务内存的优点在于,它能在需要时防止错误,同时在可能时允许并发并行,从而获得良好性能。因此,人们认为事务内存是一项有前景的技术。

目前,事务内存有两种类型:

  • 硬件事务内存:由处理器处理。Intel、Sun和IBM等公司的商用处理器已实现某种形式的硬件事务内存。但由于在硬件中实现,它存在限制。例如,硬件需要跟踪读写操作,而能用于跟踪的硬件资源有限,因此硬件事务内存能处理的事务大小有限(可能只能处理几百条指令的事务)。不过,由于在硬件中实现,它通常速度很快。
  • 软件事务内存:在软件中实现,因此灵活得多。例如,可以将读写跟踪信息存储在处理器内存中,从而处理大得多的事务。然而,由于在软件中实现,它通常速度很慢,目前性能往往难以接受,因此使用不多。

3. 编写正确的并发代码

最后,你可以尝试直接编写正确的并发代码。但这非常困难,尤其是对于有工期压力的程序员来说,很难确保大型并发代码中没有错误。事实上,即使是编写既正确又高效的简单并发程序,也足以作为研究课题发表论文。

因此,事务内存尚未成熟,而强制人们编写自己的并发代码也不是实际的解决方案。所以,我们仍需回到临界区和锁这个旧方案上。我们将重点关注这个问题。

互斥

临界区和锁都实现了一种称为互斥的属性。接下来我们讨论互斥。

互斥算法需要满足几个属性。假设有n个并发进程,它们可能以不同的速度运行,并且希望获取某种称为临界区的资源(可以是一段代码或一个内存位置)。

互斥算法需要满足以下属性:

  1. 互斥属性:这是安全性属性。不能有两个进程同时处于临界区中。如果不满足,代码可能会做坏事,违反了安全性。
  2. 进展属性:我们需要确保系统能取得进展。例如,满足互斥属性的一种方法是停止所有线程,但这毫无用处,因为我们没有做任何有生产力的事情。因此,我们需要进展属性。

进展属性有不同的定义:

  • 无死锁:系统不会死锁,即不会出现所有进程都卡住的情况。换句话说,如果有多个进程想要进入临界区,那么至少有一个进程最终会在某个时间内成功进入。同样,如果只有一个进程想进入,它当然也能进入。
  • 无饥饿:这是一个更强的进展属性。它要求,如果有多个进程想要进入临界区,那么每个进程都能在有限时间内进入。无死锁只要求至少有一个进程能进入,并且该进程可能多次进入。而无饥饿要求最终每个进程都有机会进入临界区。

通常,满足无死锁属性比满足无饥饿属性容易得多。这意味着你可以编写出非常高效且满足无死锁的算法,但如果要求算法无饥饿,则算法必须昂贵得多,而且通常不值得为此付出高昂代价。因此,通常我们只满足于拥有一个无死锁的算法。

此外,即使你有一个无死锁算法,在实践中它通常也能像无饥饿算法一样工作。因为实际执行取决于许多随机因素(如线程如何交错等),所以尽管可能存在某些线程永远被锁定的交错情况,但这些情况不太可能发生,尤其是长时间运行时。因此,如果你采用一个简单高效的无死锁算法,几乎可以保证最终每个人都能进入临界区。

接下来,我们将尝试实现这些互斥算法。我们将研究如何实现它们,而算法的类型取决于硬件支持的操作。例如,所有处理器都支持读写操作,但有些处理器还支持额外的硬件同步操作,如测试并设置或比较并交换。拥有这些额外操作可以更轻松、更高效地编写互斥算法。因此,我们将根据实际使用的操作来研究不同类型的算法。

在此之前,我们先介绍两个同步原语:测试并设置和比较并交换。

  • 测试并设置:应用于一个布尔变量x。它告诉我们x当前是否为真。假设x初始为假,执行测试并设置会返回响应false(因为当前x为假),同时将x原子性地设置为true。如果x当前为真,则返回响应true,且x保持不变(仍为true)。测试并设置原子性地完成这两个步骤(读取当前值并设置新值)。
  • 比较并交换:操作一个变量x和两个值vv‘。比较并交换会测试x当前是否等于v。如果相等,则将x原子性地设置为新值v‘;如果不相等,则x不变。同时,它返回x的当前值。所有这些步骤都是原子性发生的。

事实证明,如果你有比较并交换操作,实际上可以实现任何并发算法。对于测试并设置,它不足以实现某些算法,但一旦有了比较并交换,你就可以实现任何算法。不过,我们只对互斥算法感兴趣,而使用比较并交换或测试并设置都可以实现互斥。

另外,我们还需要注意处理器的内存模型问题(这里不过多讨论)。内存模型意味着处理器可以有强内存模型或弱内存模型,并且处理器可以重排指令。真正的处理器会重排指令,但如果你的处理器这样做,实际上可能会破坏许多锁算法。大多数锁算法假设顺序一致的内存模型,这意味着如果一个进程应该执行一系列步骤(如1、2、3),那么在实际执行中这些步骤不会被打乱顺序。它们可以以任何方式与其他进程的步骤交错,但不会在自身内部被重排。

互斥算法的实现

现在我们已经了解了什么是互斥,接下来尝试实现互斥算法。我们将从一个简单的例子开始,这个算法甚至不能正确工作,但它说明了基本概念,然后我们可以稍作修改以得到一个正确的算法。

首先,我们只想实现两个进程的互斥,并且只使用基本的读写操作。

其工作原理是,如果有两个进程想要进入临界区,它们基本上需要知道彼此的存在,以免同时进入。我们需要某种方式让两个进程相互通知。

实现方式是使用这些共享变量——标志。两个进程,每个都有一个标志。如果标志为真,则告诉另一个进程“我想进入”。我举起我的标志,告诉对方我想进入临界区;对方也有一个标志,如果他举起标志,我就知道他也想进入。

算法非常简单:

  1. 如果你想进入临界区,就调用锁函数。这意味着我想获得锁,进入临界区。我只需举起我的标志,告诉对方我正在尝试进入。
  2. 为了防止我们俩同时进入,为了满足互斥,我会说:如果我看到对方的标志举起了,我就等待他。只要他的标志举着,我就继续等待(这是一个循环)。
  3. 解锁算法是:这意味着我完成了临界区的使用,然后我只需放下我的标志,因为我不再需要它了。

事实证明,这个算法部分正确。它满足互斥。例如,假设有两个线程A和B,它们都想进入临界区,都会调用锁算法,执行第7行。其中一个会先执行第7行。假设A先执行第7行,A将其标志设置为真。后来B执行第7行,然后B执行第8行。当B执行第8行时,它会检查A的标志,发现其为真(因为A已经执行了第7行),因此B会等待。所以,如果A想进入临界区,B不会也进入。总是会有一个进程先执行第7行,另一个进程会等待,因此我们不会有两个进程同时处于临界区,从而满足互斥。

然而,在某些情况下,这个算法无法取得进展。它不是无死锁的,甚至不是无饥饿的。原因基本上是,如果两个进程都想进入临界区,它们最终可能会互相等待。例如,假设A执行第7行,B执行第7行,然后B执行第8行,接着A执行第8行。B会看到A想进入临界区,B将在其第8行循环等待。同样,A会看到B已经表示想进入临界区,A将在其第8行循环等待。现在,它们都将永远循环下去,因为它们都卡在第8行,永远无法进入临界区。

这就像两个人过于礼貌的情况。假设你和我都想通过一扇门。如果我对你礼貌,看到你试图通过,我会让你先走,我会等你。但如果你也很礼貌,你也会等我。如果我们都太礼貌,最终将永远等待对方。这正是这个算法中发生的情况。

现在,我们需要看看如何稍微修改这个算法,以便取得进展。当然,我们仍然保持互斥属性,但现在我们还要有进展。这个新算法称为Peterson算法。同样,它仅适用于两个进程。

它基本上与之前的算法相同,我们仍然有这些标志。如果我想进入临界区,我仍然会举起我的标志。但现在我们多了一个叫做“受害者”的东西。受害者将是其中一个进程。

其工作原理是:如果我想进入临界区,我会调用锁,然后在第8行举起我的标志,并在第9行将受害者设置为我自已。这试图表达的是:“我会等你”。受害者就像那个不能进入临界区的人。victim = I意味着我自愿不进入临界区。但如果你也想进入临界区,你也自愿不进入临界区。

看起来之前可能发生的死锁问题在Peterson算法中可能再次发生,但之所以没有发生,是因为这里的受害者是由我们俩共享的。我们写入同一个受害者变量。这与标志不同,标志是每人一个,我们有两个标志,但只有一个受害者变量,它由我们俩共享。

这就像我们有一个共同的白板或黑板可以写字。如果我想通过这扇门(获得资源),我会把我的名字写在上面,意味着我会等你。但如果你也想进入,你会擦掉我的名字,然后写上你的名字,现在你愿意等待。这就是第9行所做的。

然后第10行,我们如何决定谁进入?我们再次检查对方的标志。如果对方的标志举起了(他想进入临界区),并且victim == I(意味着我要等待),那么我实际上会等待。如果我看到你想进入并且受害者是我自己,那么我就等待。但是,如果我看到要么你的标志是假的(你不想进入临界区),那么我就不等你;或者我看到你的标志举起了,但你自愿成为受害者(victim不等于I,而是等于另一个进程J),那么我也不等待,因为你自愿成为受害者,所以我会通过。

现在检查它是否仍然正确工作(实际上它工作得更正确了,因为现在我们拥有了所有属性):

  1. 互斥:看第9行,无论哪个进程最后执行第9行,假设进程I是最后执行第9行的进程。两个进程都会执行第9行,其中一个会最后执行。因此,它们都已经执行了第8行,都将自己的标志设置为真。由于I最后执行第9行,受害者的最终值将等于I。而且,由于另一个进程J已经设置了他的标志,当I执行到第10行时,他将满足两个条件,因此他会等待另一个进程。所以,I肯定不会也进入临界区。因此,我们不能有两个进程同时处于临界区,满足互斥。
  2. 无死锁:唯一可能死锁的方式是两个进程都在等待,而唯一等待的地方是第10行。我们需要检查不能有两个进程同时在第10行等待。原因在于,查看最后执行第9行的那个线程,他设置受害者为他自己,所以只有那个线程能同时满足两个条件(受害者等于他自己,并且看到对方的标志举起)。另一个先执行第9行的线程,他可能看到对方的标志举起,但受害者不会等于他自己,因为他先执行第9行,然后另一个线程执行了我的第9行,所以另一个线程将受害者切换为另一个人。因此,这个算法是无死锁的,实际上也是无饥饿的。

尽管这只适用于两个进程,但实际上你可以将许多个这样的算法副本连接起来,以获得一个n进程的互斥算法。

接下来,我们将看一个叫做Lamport算法(著名的面包店算法)的算法。这是另一个互斥算法,专为n个进程设计,而不仅仅是两个进程。

它之所以被称为面包店算法,是因为它涉及一个取票过程,就像你去面包店时,为了决定谁先得到面包,每个人都取一张票,然后按照取票的顺序接受服务。

实际上,这段代码可以看作有两个部分:

  1. 门口部分:在这里你取票(在面包店)。
  2. 等待部分:取票后,你需要等待叫到你的票号。

我们可以证明,门口部分每个人都在有限步数内完成,因为门口没有循环,唯一的循环在等待部分。

这个面包店算法有一个很好的先到先服务属性。意思是,如果一个进程I先完成其门口部分(门口部分是一些代码行),在另一个进程J甚至还没开始算法之前,那么I肯定会在J之前进入临界区。如果IJ同时运行它们的门口部分代码,那么我们不知道谁会先进入临界区。但如果I确实在J之前到来,就像I已经完全进入面包店并拿到了票,然后另一个人J才来,那么那个人J不会排在我前面。

由于这个先到先服务属性,你可以证明这个算法实际上是一个无饥饿的n进程算法。因为每个进程最终都会完成其门口部分(那里没有循环),之后,只有那些现在也在门口的线程才能在我之前进入临界区。如果现在有人不在门口,那么他将是那些在我完成门口之后才开始算法的J之一。根据先到先服务属性,他将在我之后进入。因此,只有与我并发的线程才能在我之前进入,而这些线程的数量是有限的,所以我永远不必无限期等待,只需要等待我的并发线程完成即可。

让我们看看这个Lamport算法。我们再次有这些标志,现在由于有n个线程,我们有n个标志,每个进程一个。标志只是为了告诉其他进程我想进入临界区。此外,你还有这个叫做“标签”的东西,标签只是一个数字。

初始时,所有标志都是假,标签都是零。基本上,为了进入临界区,我要做两件事:举起我的标志让人们知道我在这里,然后获取一个标签。标签对应于我的票号,标签越小,我的票越早,我就越快进入。

在代码中:

  • 第13行:我设置我的标志。
  • 第14行:我需要我的标签。如果我看到一堆其他线程已经在门口等待(它们都已经拿到了票),那么我取一个更小的票是不公平的,我应该取一个更大的票。所以,我要做的是读取所有其他人的标签,取这些值的最大值,然后加一。这样,我拿到的票就比所有人的票号都大。
  • 第15行:接下来,我决定是否可以进入临界区。我会检查两件事:检查所有其他进程(我知道最多有100个其他进程,所以我会检查所有那些其他进程)。如果我发现任何其他进程K正在尝试进入(其标志为真)并且K的标签比我小,那么我就等待。

现在谈谈这个标签。标签是一个数字, potentially 两个线程可能获得相同的标签。例如,假设当前的标签是:进程1标签为2,进程2标签为3,进程3标签为1。如果我是进程4,我来读取这三个标签,然后我会选择4作为我的标签。现在,有可能另一个进程5基本上与进程4同时到来,他也读取这些标签2、3和1,那么他也会将他的标签设置为4。现在,进程4和进程5的标签出现了平局。这是一个问题,因为我们需要比较这些标签。

为了解决这个问题,我们不仅仅比较标签数字,而是比较一个由标签和进程ID组成的。例如,对于进程4,标签是4,进程ID是4,所以对是(4,4)。对于进程5,标签是4,进程ID是5,所以对是(4,5)。如果你用字典序比较这些对(首先比较第一个数字,如果第一个数字相同再比较第二个数字),那么你永远不会在两个数字上都出现平局,因为所有线程都有不同的ID。

因此,进入临界区的条件是:检查是否有任何其他进程的标志为真并且他的标签(或标签-进程ID对)比我的小。如果有,我就等待那个人;如果没有这样的人,那么我就进入临界区。

现在我们要证明这个算法是正确的:

  1. 无死锁:原因是,在任何时间点,如果你比较所有进程的所有标签,这些标签是可以完全排序的(永远不会有两个标签相同),因此存在一个最小标签。在任何时候,这些进程都有一些标签,但其中有一个最小标签。当那个拥有最小标签的进程执行第15行时,他不会看到任何标签比他更小的人(因为他拥有最小标签),因此拥有最小标签的进程肯定不会在第15行等待。所以,至少那个人可以进入临界区。
  2. 先到先服务属性:我们声称,如果某个线程I先完成其门口部分,在另一个线程J开始锁操作之前,那么I将在J之前进入临界区。原因是,如果I已经完成了其门口部分,那么I已经获得了一个标签,并且他已经将该标签写入他的标签数组(全局标签数组)。现在进程J启动,当J执行第14行时,他最终会读取I的标签(I已经写入了他的标签)。然后J将选择一个更大的标签,所以J的标签总是大于I的标签。因此,J不能在I之前进入临界区,因为当J执行第15行时,他会看到I的标志已经举起并且I的标签更小,所以J不能超越I
  3. 互斥:为了证明,假设违反互斥,即IJ都在临界区中。不失一般性,假设J的标签更大。当J执行第15行时(假设J在临界区),那么J一定检查过I是否满足条件。J一定看到要么I的标志等于0,要么I的标签大于J的。因为如果I的标志等于1并且I的标签小于J的,那么J会在第15行等待,不会进入临界区。现在,I的标签大于J的标签是不可能的,因为我们已经假设J的标签更大,并且I的标签只会增加,永远不会随时间减少。另一种可能性是I的标志等于0,但I想进入临界区,并且他还没有执行第13行。因为如果I执行了第13行,他的标志就会为真。因此,I将在之后执行第13行,然后他也会在之后执行第14行。当I执行第14行时,他会读取J的标签(他在第14行读取所有人的标签),然后I会将他的标签设置为比J更大,这又与J有更大标签的假设矛盾。因此,该算法满足互斥。由于它是先到先服务的,所以也是无饥饿的;如果它是无饥饿的,那肯定也是无死锁的。

以上是我们仅基于读写操作要看的算法。现在,我们将看一些使用其他操作(如测试并设置或比较并交换)的算法。事实证明,如果我们允许这些操作,实际上可以得到非常简单的算法。

第一个基本算法是TAS锁算法(测试并设置锁)。其工作原理是,有一个名为state的变量,由所有进程共享。所有进程都看到这个变量,只有一个这样的变量statestate只是一个布尔变量,初始为假。

非常简单,如果你想进入临界区,那么你尝试将state设置为真。此外,你读取state的当前值,如果它是假,那么你获得临界区;如果它是真,那么你没有获得,必须等待。这就是整个算法。

你执行这个获取并设置操作,尝试将state设置为真。基本上,发生的情况是:初始state为假。假设进程1来了,他想获得锁,然后他执行第4行,这将把state设置为真。此外,因为获取并设置,所以进程1获取state的当前值(假),然后在该语句之后state的值变为真。进程1将看到他可以进入临界区,因为这里只在state为真时等待,而进程1看到state为假,所以进程1可以完成其锁操作并获得锁。

后来,你有另一个进程2,他将执行他的获取并设置,但现在state已经变为真。因此,当进程2执行他的获取并设置时,他将看到真,并且state继续为真。但因为进程2看到state为真,所以他将在第4行等待。同样,如果有一个进程3,他将执行他的获取并设置,然后他将看到state仍然为真,所以他也会等待。所以,只有第一个到来的进程可以获得临界区,其他人都将等待。

最终,进程1将完成他的临界区,然后他将解锁锁,将state设置为假。此时进程2和进程3仍然卡在第4行,但是一旦进程1将state设置为假,下一次他们执行这个操作时,其中一个(比如进程3碰巧执行得快一点,执行了下一步)将看到现在state为假,然后进程3可以进入临界区。但当进程3这样做时,他会将state设置为真,现在进程2执行他的获取并设置,他将再次看到state为真,所以进程2必须继续等待。

整个过程是:有一堆进程到来,他们想获得锁,锁当前设置为假。谁先执行获取并设置操作,谁就是第一个,他得到假值返回,并将锁设置为真,他进入临界区。其他线程都得到真值返回,所以他们等待。最终,拥有锁的线程完成,他将state设置为假,然后这些等待的线程仍在执行获取并设置,谁先执行获取并设置,谁就会看到state为假,他将进入,其他线程继续等待。

你可以看到,它显然满足互斥,因为只有一个线程能看到state等于假(谁先执行获取并设置,谁就看到state为假)。一旦第一个线程执行获取并设置,state就原子性地变为真(这里需要原子性属性)。然后所有后来的进程执行他们的获取并设置时,都会看到它为真,所以他们等待。因此,我们实现了互斥。

我们也有无死锁,因为当锁打开时(为假),会有一个线程第一个执行获取并设置,所以他会进入。因此,我们总是至少有一个线程获得锁。

这不是无饥饿的,因为可能有一个线程一次又一次地到来,并且他总是第一个执行获取并设置,因为基本上所有这些等待进程之间都在竞争执行获取并设置。可能总是有一个进程是第一个,所以他一次又一次地进入,而其他进程必须永远等待。

接下来,这个TAS锁是正确的,但性能不好。性能不好的原因是这些获取并设置操作。在共享内存系统中实现时,为了效率,你肯定希望拥有缓存一致性,但获取并设置的实现方式总是会导致缓存一致性流量。问题是,如果你有一堆线程竞争临界区,他们都在调用这个函数,所以他们都在相互竞争,发送大量的缓存一致性消息。事实上,线程越多,发送的缓存一致性消息就越多,这个算法就越慢。

理想的锁应该是,无论有多少线程想要进入临界区,所需的时间都相同(比如一微秒)。对于TAS锁,线程越多,最终将花费大量时间,性能确实很差。

接下来,我们将看一个稍作修改的版本,称为TTAS锁(测试-测试并设置锁)。TAS是测试并设置,TTAS是测试-测试并设置。

其工作原理是,之前的TAS锁算法在获取并设置操作上自旋,这反复导致缓存一致性流量。而TTAS锁不会在测试并设置上自旋,而是在一个常规的读变量上自旋,它会反复读取一个变量。

然而,如果你反复读取一个变量,你可以将该变量缓存到你的缓存中,然后只要该读变量的值不变,你就不会引起任何额外的缓存一致性流量。获取并设置变量无论值是否改变,总是会引起缓存一致性流量;而读操作只在变量值改变时才会引起流量。

state值就像之前一样,表示锁当前是否被锁定。如果当前被锁定,则state等于真。如果我只是在这个state上自旋,那么它继续保持为真,所以没有缓存一致性流量。这个算法基本上是检查(读取)state,是真还是假?如果是真,我就等待。最终,在临界区中的那个线程将解锁,将state设置为假。在那一刻,缓存一致性硬件将通知等待进程state现在为假。当我执行读操作时,我将读到假。一旦我读到假,我就继续执行第6行,并尝试成为第一个将state设置为真的人。我执行原子性的获取并设置。如果我是第一个,那么我可以进入临界区;如果我不是第一个,那么我必须回到这里的等待。但同样,当我等待时,我只进行读取,所以不会引起太多缓存一致性流量。

如果你看TTAS锁的性能,它会比TAS锁好,但仍然不理想,因为仍然有缓存一致性流量发生,而且线程越多,流量就越多,因为线程仍然需要能够获取state的当前值,缓存硬件需要做这个工作。另外,当state变为假时,我们都会竞争尝试将state设置为真,这也会在通信基础上引起许多消息发送。因此,性能仍然会随着线程数量的增加而下降。

接下来,我们将看一种叫做基于退避的锁。这个退避机制与之前的TTS算法非常相似。如果你看这部分,非常相似,唯一的区别是我们这里有一个退避操作。其思想是,如果我试图获取锁,我执行这个获取并设置操作,如果我失败了,那意味着一定有其他人也想获取锁。此外,假设我多次失败,我尝试一次,失败了,意味着别人进入了,然后我继续等待,后来state变为假,我再次尝试第9行,也许我又失败了。假设我多次失败,那意味着有许多其他线程在与我竞争。

如果有很多其他线程与我竞争,那么也许我应该退让一点,不要那么拼命竞争。因为如果我仅仅与这些家伙竞争,只有我们中的一个会赢,同时我们会引起很多问题,引起很多缓存一致性流量。相反,如果我看到有一堆其他人在等待,我会去别的地方。就像如果我试图进入一家餐厅,有100个人在等,我不会等,我会去别的地方,然后也许稍后再回来。后来当我回来时,可能只剩下两个人了,那时我再等。这就是退避的意思。意味着如果我多次在第9行失败,那么每次失败,我都会离开,并且我会离开更长的时间,因为我失败的次数越多,就越表明有很多人在等待,所以我不想与这些人竞争,我只想去别的地方,然后稍后再回来。

我等待的时间将是随机的一段时间,因为如果一群人在竞争,并且他们都退避,但如果他们退避相同的时间,那么他们都会在同一时间回来。所以我不想退避相同的时间,我会在最小延迟和最大延迟之间随机选择一个退避时间。此外,每次我未能获取锁时,我都会增加我的最大延迟值。

这就是基本算法。它正确工作,就像之前的TTS算法一样,因为唯一的改变是,我最终等待一段时间,而不是纯粹在第8行自旋(这有点浪费时间),我会离开然后退避一会儿。

这个退避思想实际上用于许多争用解决算法中。例如,在以太网中,我们需要争用解决,因为一次只能有一个进程在以太网上发送。因此,如果我看到介质上有很多争用,那么我会等待一会儿,并且我等待的时间随着争用次数的增加而增加。对于以太网,我们也有这个指数退避过程。

但有一个问题:等待多长时间?因为如果我等待的时间太短,那么当我回来时,可能仍然有一群人在竞争,那么我将不得不再次等待。所以我不想等待非常短的时间。但是,如果我等待很长时间,例如我去一家餐厅,看到只有一个人在排队,然后我等了两个小时,那我当然等得太久了,因为那个人可能五分钟后就有座位了,所以我应该只等五分钟。事实上,在这些算法中,你不知道有多少其他线程在与你竞争,因为你只知道:我执行了获取并设置,但没有得到,这意味着有人与我竞争,但我完全不知道有多少人与我竞争。所以我真的没有很好的方法来设置这个退避周期。事实上,寻找退避策略,人们仍在研究如何做到这一点,有很多不同的方法。

接下来,我们将看另一类算法,称为队列锁。我们将看几个队列锁。第一个是Anderson的队列锁

基本思想是,我们将形成一个队列。进程以某种方式排队(不同的算法有不同的构建队列机制)。基本上,最终你可以把这些线程想象成在一个队列中,它们排成一队,只有队首的线程可以进入临界区,他可以进入CS,其他线程只是在自旋(这些小圆圈表示它们在自旋)。最终,这个队首的进程P1将完成临界区,然后当他完成时,他会让下一个等待的线程进入,但P3和P2继续等待。

有不同的方法来实现这个队列锁的概念。Anderson算法实现队列的方式是,他将队列实现为一个数组。这基本上就是队列,这是队列的头部。初始时,我们有一个环形数组。Anderson算法要求你知道有多少进程想使用临界区,必须有一个上限。假设在我们的例子中,最多有8个进程想要这个临界区,那么你使用一个大小为8的环形数组。在这个数组中,所有的值都是布尔值(真或假),并且最多有一个真值(实际上总是恰好有一个真值)。真值基本上是队列的头部,真值表示在该位置的人可以进入队列。

发生的情况是,所有线程都在竞争获取这个数组中的一个位置。如果你获得一个位置并且该位置的值是假,那么你不在队列头部,必须等待。但如果你获得一个位置并且它是真,那么你在队列头部,可以进入临界区。后来,假设我在队列头部,在这个例子中,这是队列中唯一的真值部分,这个线程A在临界区中。所有其他家伙,他们获得数组中的一个位置,并且他们总是会获得连续的一串位置(队列从这里开始,你不能跳过一些位置等等)。这些其他想要进入临界区的线程,他们正在读取他们的布尔变量,只要那是假,他们就继续等待。

最终,临界区中的这个线程完成了,他想要离开临界区,并且他希望队列中的下一个线程能够进入。例如,如果P4完成,那么他将离开,然后他将让P3进入。为了实现这一点,你只需将这个标志设置为假,然后这个线程,他将设置他的下一个标志为真。然后线程B看到:哦,我的标志现在变成了真,我现在在队列头部,我可以进入临界区了。最终,当这个线程(现在B在CS中)完成时,他将把他的标志设置回假,并将下一个标志设置为真。因此,在B之后等待的人现在可以获得它。

接下来我们应该讨论的是,所有线程都需要在队列中获得一个位置,而且这些位置需要是连续的(比如占据索引2、3、4、5、6,我们不希望占据索引2、4、6等等)。获取位置的方式是,有一个叫做tail的东西,它指向队列的末尾。现在,如果我想进入队列(比如我是线程C),我想把自己放入队列,以便最终能进入临界区,那么我将对这个tail执行获取并递增操作。获取并递增将获取tail的当前值(比如4),所以我现在获得位置4。并且原子性地,它将设置tail,它将原子性地将tail递增到5。因为我们这里有一个环形队列,如果tail达到8,那么我们将取模大小(8),所以tail实际上会变成0。

所以,再次强调,有两个部分:当我想获取锁时,我首先通过对tail执行获取并递增来获得一个位置,这把我放在队列的末尾。然后我只是等待我的时隙,我一遍又一遍地读取我的时隙。如果我的时隙变为真,那么我可以进入临界区。总是有一个时隙为真,无论哪个线程在那个时隙中,一旦他完成临界区,他将把下一个时隙设置为真。

这个算法实际上性能很好,因为唯一昂贵的部分是这里的while循环(第19行),但这里我只是自旋,我一遍又一遍地读取我的标志。我们说过,读操作不会引起太多一致性流量,因为我可以缓存这个flags[slot]变量。然后最终我前面的那个线程会将我的标志设置为真,在那一刻,会有某个时间点产生一些一致性流量,但它被最小化了。所以这个Anderson算法性能良好。现在的一个缺点是,我们必须对有多少线程有一个上限。

接下来,我们将看CLH算法,以Craig、Hagersten和Landin三人命名。这个算法不需要对进程数量有上限。它同样是一个队列锁算法,但我们将以不同的方式构建队列。

同样,我们将构建一个队列,但方式不同。我们有一个tail变量指向队列的当前尾部。同样,队列头部是临界区中的那个线程,一旦他完成,他将让下一个线程进入。为了构建队列,我们还需要知道谁在队列的末尾,以便我们可以追加到队列。

所以这个tail。当你执行锁算法时,第一件事是分配一个新节点。假设我想获取锁,我将创建一个新节点,然后在第23行我将tail设置为我自己。并且,在我执行第23行之前,我执行这个获取并设置值,所以tail原来指向这里,当我执行第23行时,我的pre将被设置为tail的当前值,所以我的pre将被设置为当前的tail。例如,这是线程A,这是B,这是C。那么C.pre现在将等于B。此外,tail将被设置为这里的节点C。然后,我将查看我的前驱是否被锁定。初始时,所有线程都会锁定自己。每个节点都有一个名为locked的字段,当前是锁定的。当我开始操作时,我会先发制人地锁定自己。然后C在这里将保持自旋,他将不断读取B的这个变量locked参数,只要那是真

011:CUDA线程模型入门 🚀

在本节课中,我们将开始学习CUDA编程语言,这是一种用于对GPU(图形处理单元)进行编程的方法。

GPU的简要历史

上一节我们介绍了并行计算的基本概念,本节中我们来看看GPU的起源。我们需要回溯大约25年到20世纪90年代中期,那时第一代3D游戏开始出现。当时的计算机速度较慢,无法流畅运行这些游戏,因此人们决定创建专门的硬件来运行它们。这就是图形处理单元作为CPU的协处理器诞生的背景。例如,当时非常流行的Voodoo3显卡,可以让玩家运行像《毁灭战士》这样的游戏。尽管如今《毁灭战士》仍在推出新版本,但在当时,其图形效果被认为是革命性的。

GPU的设计哲学与特点

GPU的设计哲学与CPU有根本不同。CPU旨在最小化每个计算任务的延迟,力求让每个计算都快速完成。而GPU则旨在最大化吞吐量,即单位时间内能完成的工作总量,它不关心单个任务需要多长时间。

以下是CPU与GPU的主要区别:

  • CPU:采用多核处理器,核心数量较少(通常几十个),但每个核心架构复杂,包含分支预测、乱序执行、大容量缓存等特性以加速单个任务。
  • GPU:采用面向吞吐量的处理器,拥有数千个核心。每个GPU核心比CPU核心简单得多,专注于计算,去除了许多复杂的CPU特性,因此体积更小,能在相同芯片面积上集成更多核心。

例如,基于Pascal架构的Tesla P100 GPU拥有56个流式多处理器,每个包含64个核心,总计3584个核心,可提供11万亿次浮点运算的性能。相比之下,高端CPU如Intel Xeon拥有24个核心,总性能约为3万亿次浮点运算。

GPU的适用场景与限制

由于GPU拥有远超CPU的原始计算性能和更高的能效,它们被广泛应用于许多领域。然而,GPU并非万能,它们主要针对特定类型的工作负载进行了优化。

GPU最适合数据并行任务。数据并行的核心思想是:对多个数据项应用相同的操作。其架构基于单指令多数据模型,即许多核心同时执行相同的指令,但操作不同的数据。

以下是数据并行任务的例子:

  • 向量加法:对两个向量的每个对应分量执行加法。
  • 线性代数运算:如矩阵向量乘法,其计算模式与向量加法类似。
  • 计算机图形学:渲染图像时,对每个像素执行基本相同的处理过程。
  • 深度学习:大量运算可归结为密集的线性代数运算。

GPU在处理需要大量线程间同步或不规则工作负载(即线程执行不同指令)的任务时效率较低,甚至可能无法运行。

CUDA编程简介

CUDA是NVIDIA推出的、用于对其GPU进行通用计算的编程模型和平台,现已成为GPU编程的事实标准。CUDA基于C语言,通过添加扩展来实现。

除了CUDA,还有其他GPU编程方式:

  • OpenCL:开放标准,语法和概念与CUDA相似,更具可移植性(支持AMD GPU、CPU等),但性能通常略低于CUDA。
  • OpenACC:基于指令的编程模型,类似于OpenMP,易于使用但性能通常不及CUDA。

编写和运行CUDA程序的基本步骤如下:

  1. 主机初始化:程序在CPU(称为主机)上开始执行,进行初始化和串行计算。
  2. 设备内存分配:在GPU(称为设备)上分配内存。
  3. 数据传输:将需要计算的数据从主机内存复制到设备内存。
  4. 内核启动:指定线程配置,调用在GPU上执行的函数(称为内核)。
  5. 结果回传:将计算结果从设备内存复制回主机内存。
  6. 资源释放:释放设备上分配的内存。

主机和设备之间通过cudaMemcpy函数进行数据传输,并使用cudaMalloc在设备上分配内存。

CUDA线程组织与内核

在CUDA中,线程被组织成一个两层结构:

  1. 网格:由多个线程块组成。
  2. 线程块:包含多个线程

启动内核时,需要指定网格维度(即线程块的数量)和线程块维度(即每个线程块中的线程数)。线程可以通过内置变量唯一标识自己:

  • blockIdx:线程所在块的索引。
  • threadIdx:线程在其块内的索引。
  • blockDim:线程块的维度(即每个块有多少线程)。
  • gridDim:网格的维度(即有多少个线程块)。

以下是一个向量加法的CUDA内核示例:

__global__ void vectorAdd(float *A, float *B, float *C, int n) {
    // 计算当前线程对应的全局索引
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    // 确保索引在数组范围内
    if (i < n) {
        C[i] = A[i] + B[i]; // 每个线程计算一个加法
    }
}

主机代码调用该内核的示例:

// 假设每个块有256个线程
int threadsPerBlock = 256;
// 计算需要的块数,确保总数至少为n
int blocksPerGrid = (n + threadsPerBlock - 1) / threadsPerBlock;
// 启动内核
vectorAdd<<<blocksPerGrid, threadsPerBlock>>>(d_A, d_B, d_C, n);

这种映射方式确保了向量中的每个元素都由一个唯一的线程处理。

线程执行模型与GPU硬件

GPU硬件由多个流式多处理器组成,每个SM包含多个核心(如32或64个)。虽然可以启动数百万个线程,但GPU上同时活跃的线程数受限于SM的数量和每个SM的资源。

GPU通过两层调度高效管理大量线程:

  1. GigaThread引擎:将线程块调度到可用的SM上执行。
  2. SM warp调度器:在每个SM内部,线程被进一步分组为warp(通常是32个线程)。SM的调度器负责在多个warp之间快速切换。

线程切换开销极低的关键在于资源预分配。当一个线程块被调度到SM上时,SM会为其预分配所需的寄存器、共享内存等资源。切换线程时,只需切换指向不同寄存器组的指针,无需将数据保存到慢速内存中,因此几乎没有延迟。

同步与块大小选择

在CUDA中,同步能力有限:

  • 线程块间:无法同步,线程块的执行顺序是不确定的。
  • 线程块内:可以使用__syncthreads()函数进行屏障同步,确保块内所有线程都到达同步点后再继续。

选择线程块大小时,目标是最大化占用率,即每个SM上同时活跃的线程数。这受到硬件限制的约束:

  • 每个SM的最大线程数(如1536)。
  • 每个SM的最大线程块数(如8)。
  • 每个线程块的最大线程数(如1024)。
  • 寄存器数量限制。

通常,需要平衡考虑。块大小过小可能导致SM无法被完全利用;块大小过大可能受限于每个SM的线程块数量或寄存器资源,并可能因线程间工作负载不均或同步等待而导致效率下降。NVIDIA提供了分析工具来帮助确定代码的最佳占用率和块大小。

总结

本节课中我们一起学习了CUDA编程的基础知识。我们从GPU的历史和设计哲学讲起,理解了其面向吞吐量、适合数据并行任务的特点。我们介绍了CUDA作为主流的GPU编程模型,学习了其主机-设备交互模式、线程的两层组织架构(网格和线程块),以及如何编写和启动一个简单的内核。我们还探讨了GPU硬件的执行模型,了解了warp调度和资源预分配如何实现高效的大规模线程管理。最后,我们讨论了CUDA中同步的局限性以及选择合适线程块大小的考虑因素。掌握这些概念是进行高效GPU编程的第一步。

012:GPU内存架构与Warp概念

在本节课中,我们将要学习GPU的内存系统以及一个核心概念:Warp。理解内存层次结构对于在GPU上获得高性能至关重要,因为许多程序的瓶颈在于内存访问速度,而非计算能力。

内存层次结构

上一节我们介绍了GPU并行计算的基本模型,本节中我们来看看GPU的内存系统。为了应对处理器与内存之间的性能差距,计算机采用了内存层次结构。GPU的内存层次结构包含多个层级,它们具有不同的大小和速度。

以下是GPU内存层次的主要组成部分:

  • 全局内存:这是GPU上最大但最慢的内存,容量通常在1到32GB之间。所有线程都可以访问全局内存,但其延迟高达数百个计算周期,带宽也有限。
  • 共享内存/L1缓存:位于流式多处理器上,速度远快于全局内存。一块约64KB的物理内存可以在运行时被配置为共享内存和L1缓存。共享内存由程序员显式控制,L1缓存则由硬件自动管理。其可见性仅限于单个线程块。
  • 寄存器:速度最快、带宽最高的内存,位于SM上。每个线程拥有私有的寄存器,其他线程无法访问。寄存器的状态在Warp切换时通过改变指针快速切换,这是实现低开销多线程的关键。

全局内存访问优化

由于大部分数据存储在全局内存中,优化其访问是性能提升的关键。全局内存面临两大挑战:高延迟和有限带宽。

延迟隐藏

全局内存访问延迟高达数百周期。如果线程在内存操作完成前一直等待,性能将极其低下。GPU采用大规模多线程技术来隐藏这种延迟。

其工作原理是:当一个线程发起高延迟的内存操作后,SM调度器不会让该线程空等,而是迅速切换到另一个就绪的线程去执行。通过在许多线程之间快速切换,可以有效地将内存访问的延迟与计算重叠起来,从而保持计算核心的忙碌。

为了实现有效的延迟隐藏,需要满足两个条件:

  1. 线程切换必须非常快速。GPU通过为每个线程块预分配寄存器,并在Warp间切换时仅需更改寄存器文件指针来实现。
  2. 必须有足够多的线程可供切换。这就是为什么我们需要追求较高的SM占用率,即在每个SM上调度尽可能多的线程,以确保总有就绪的线程可以执行。

带宽优化与数据复用

即使隐藏了延迟,全局内存的有限带宽也可能成为瓶颈。例如,在矩阵乘法中,每个输出元素的计算与内存访问之比可能很低,导致性能受限于内存带宽。

解决方案是数据复用,即减少从全局内存读取的数据量。以矩阵乘法为例,在朴素算法中,同一行或同一列的线程会重复读取相同的数据块,造成大量冗余的全局内存访问。

我们可以利用共享内存来优化:

  1. 让一个线程块内的线程协作,将所需的一小块数据(Tile)从全局内存一次性加载到共享内存中。
  2. 所有线程随后从高速的共享内存中重复读取这部分数据进行计算。
  3. 计算完当前Tile后,再加载下一个Tile,如此迭代。

这种方法并未减少总的内存访问次数,但将大量缓慢的全局内存访问替换成了快速的共享内存访问,从而显著提升了有效带宽。

以下是分块矩阵乘法的核心代码结构示意:

__global__ void matrixMulTiled(float* d_M, float* d_N, float* d_P, int width) {
    // 1. 在共享内存中声明Tile
    __shared__ float s_M[TILE_WIDTH][TILE_WIDTH];
    __shared__ float s_N[TILE_WIDTH][TILE_WIDTH];

    int bx = blockIdx.x; int by = blockIdx.y;
    int tx = threadIdx.x; int ty = threadIdx.y;

    int row = by * TILE_WIDTH + ty;
    int col = bx * TILE_WIDTH + tx;

    float pValue = 0;

    // 2. 分阶段循环
    for (int p = 0; p < width/TILE_WIDTH; ++p) {
        // 协作加载数据到共享内存
        s_M[ty][tx] = d_M[row*width + (p*TILE_WIDTH + tx)];
        s_N[ty][tx] = d_N[(p*TILE_WIDTH + ty)*width + col];

        // 3. 屏障同步,确保Tile数据加载完毕
        __syncthreads();

        // 4. 从共享内存读取数据进行计算
        for (int k = 0; k < TILE_WIDTH; ++k) {
            pValue += s_M[ty][k] * s_N[k][tx];
        }

        // 5. 屏障同步,确保所有线程完成当前Tile计算
        __syncthreads();
    }

    // 6. 将结果写回全局内存
    d_P[row*width + col] = pValue;
}

使用大小为 M 的Tile,可以将全局内存访问量减少为原来的 1/M。例如,使用16x16的Tile,性能可提升约16倍。

内存声明与同步

我们了解了不同内存的用途,现在来看看如何在代码中声明它们以及如何进行线程同步。

内存声明

  • 寄存器:在函数内声明的自动变量(非数组)通常存储在寄存器中。作用域为单个线程,生命周期随线程结束。
  • 共享内存:使用 __shared__ 限定符声明。作用域为整个线程块,生命周期随线程块结束。
  • 全局内存:在主机端使用 cudaMalloc 分配,或在设备代码中使用 __device__ 限定符声明。作用域为整个网格(所有线程),生命周期持续到应用程序释放。

线程同步

在CUDA中,线程块内的线程可以使用 __syncthreads() 进行同步,这是一个屏障操作。所有线程必须都到达这个调用点,才能继续执行后续代码。这在分块算法中至关重要,例如确保所有线程完成共享内存的数据加载后再开始计算。

使用 __syncthreads() 时需注意:

  • 必须确保同一线程块中的所有线程都能执行到该调用。如果存在分支,且屏障只出现在某个分支中,可能导致死锁。
  • 同步可能引入等待开销,应谨慎使用。

注意:不同线程块之间无法直接同步,它们的执行顺序是不确定的,可能并发执行。

Warp:执行的基本单位

接下来,我们深入探讨GPU执行模型的核心——Warp。一个Warp是SM上调度和执行的基本单位,通常包含32个线程。SM希望一个Warp内的所有线程能以锁步(SIMD)方式执行相同的指令,这样才能达到最高效率。

Warp层面的优化主要有三个目标:合并内存访问避免分支发散避免存储体冲突

1. 合并内存访问

这是针对全局内存的优化。全局内存被划分为连续的段(如128字节)。当一个Warp中的所有线程访问同一段内连续的内存地址时,硬件可以合并这些访问,只需一次内存事务即可传输所有数据,这称为合并访问

反之,如果Warp内的线程访问的内存地址分散在不同的段中,硬件可能需要对每个段发起单独的内存事务,即使其中很多数据并不需要。这会严重浪费带宽,称为非合并访问

示例:访问行主序存储的矩阵。

  • 按行访问:Warp读取连续内存地址,是合并访问。
  • 按列访问:Warp读取的地址间隔很大(跨行),通常是非合并访问。

优化技巧:如果算法需要非合并访问模式(如按列访问),可以先将数据从全局内存按行(合并方式)加载到共享内存,然后再在共享内存中进行所需的非连续访问,从而避免全局内存的非合并访问。

2. 避免分支发散

由于Warp以SIMD方式执行,如果Warp内的线程在执行时遇到条件分支(如 if 语句),并且不同的线程走了不同的分支路径,就会发生分支发散

发生分支发散时,GPU必须串行化执行所有不同的分支路径。例如,一个Warp中一部分线程执行 if 块,另一部分执行 else 块,那么SM会先执行所有走 if 路径的线程(其他线程空闲),然后再执行所有走 else 路径的线程。这会导致有效执行时间增加。

示例:归约求和。

  • 朴素算法:在每次迭代中,只有一部分线程(如索引为偶数的线程)执行加法操作,另一部分线程空闲。这导致Warp内部分支发散。
  • 优化算法:改变配对策略(从大跨度开始),可以确保在最初的几次迭代中,整个Warp要么都执行操作,要么都不执行,从而避免了分支发散,提升了性能。

3. 避免存储体冲突

这是针对共享内存的优化。共享内存被划分为多个(通常是32个)存储体。理想情况下,一个Warp中的32个线程应同时访问32个不同的存储体,这样可以并行处理,效率最高。

如果同一个Warp内有两个或更多线程访问同一个存储体中的不同地址,就会发生存储体冲突。冲突的访问必须被序列化,从而增加访问延迟。如果所有线程访问同一存储体的同一地址,则会发生广播,不视为冲突。

示例:多个线程连续加载数据到共享内存时,如果访问模式设计不当,可能导致多个线程访问同一存储体。
优化技巧:通过调整数据布局或改变线程访问数据的顺序,可以化解存储体冲突。例如,使用填充字节来错开原本会映射到同一存储体的数据地址。

总结

本节课中我们一起学习了GPU编程的两个核心主题:内存架构与Warp。

  1. 内存层次结构:我们了解了全局内存、共享内存/缓存和寄存器的特性、速度差异及可见性范围。高性能编程的关键在于将数据保留在更快的内存中并促进数据复用。
  2. 全局内存优化:通过大规模多线程隐藏访问延迟,并通过利用共享内存进行数据复用来提升有效带宽。我们以分块矩阵乘法为例,详细阐述了这一优化过程。
  3. Warp概念:Warp是SM的执行单位。我们学习了如何通过实现合并内存访问、减少分支发散和避免存储体冲突来保证Warp的高效执行。理解并优化Warp级别的行为对于挖掘GPU性能潜力至关重要。

掌握这些概念是编写高效CUDA程序的基础。下一讲我们将继续探讨GPU编程的其他高级主题。

013:CUDA 3 内联函数

在本节课中,我们将继续探讨GPU编程,并学习一类称为内联函数的GPU操作。我们将主要关注如何使用内联函数来解决不同类型的同步问题。

同步问题回顾

上一节我们介绍了分布式内存和共享内存编程中的竞态条件问题。当多个线程并行执行一系列操作时,这些操作会以某种方式交错执行。不同的交错顺序可能导致程序行为不正确。这个问题在GPU编程中同样存在。

例如,观察右侧的程序。这是一个GPU内核,其中有一个名为DA的共享变量,它是一个全局内存变量,所有GPU线程都可以访问它。我们启动了1000个线程块,每个块有100个线程,总计一百万个线程。每个线程的任务是将共享变量DA的值加1。因此,我们的目标是最终DA的值变为一百万。

然而,实际运行时,你可能会得到一个完全不同的值,例如88。这是因为DA++这个递增操作,虽然看起来只是一条指令,但实际上由多个更小的指令组成。这些微指令可能以奇怪的方式交错执行,导致DA的值被覆盖而非递增。最终,变量可能只被正确递增了88次,从而得到错误答案。

为了在GPU上处理这个问题,我们可以使用一类称为内联函数的操作。

原子内联函数

我们将重点讨论原子内联函数。GPU上还有其他类型的内联函数,例如用于快速数学计算(如平方根、指数函数等),但本节课我们只关注用于同步的原子内联函数。

这些原子内联函数本质上类似于小型临界区。它们允许你将一段代码放入临界区,确保当多个GPU线程并发执行时,能得到正确结果。然而,这些临界区的性质有限制:它们只适用于非常小的代码片段,实际上只适用于单个操作。

那么,对于更大的代码段,如何在GPU上实现临界区呢?首先,在GPU上实现通用的互斥算法相当困难。其次,即使能够实现,由于GPU需要运行大量线程,如果有一段代码处于临界区,所有线程都必须顺序执行该段代码,这将导致性能极差。因此,即使能在CUDA中实现临界区,由于性能原因,我们通常也不希望这样做。

接下来,让我们具体看看这些原子内联函数。这些操作仅适用于变量,且变量可以存储在全局内存或共享内存中。

以下是几个核心的原子内联函数:

  • atomicInc:原子递增。它读取给定地址的当前值并返回,然后将该地址的值原子性地加1。
    • 代码atomicInc(address)
  • atomicAdd:原子加法。与递增类似,但可以指定一个增量值val(可为正或负)。它返回地址的旧值,并将val原子性地加到该地址的值上。
    • 代码atomicAdd(address, val)
  • atomicMax:原子最大值。读取地址的当前值,并将其原子性地设置为当前值与给定值val中的较大者。返回旧值。
    • 代码atomicMax(address, val)
  • atomicExch:原子交换。返回地址的当前值,并原子性地将其设置为新值val
    • 代码atomicExch(address, val)
  • atomicCAS:原子比较并交换。这是一个条件操作,有三个参数:地址、旧值old和新值new。它读取地址的当前值并返回。然后,如果当前值等于old,则原子性地将地址的值设置为new;否则,不进行任何更改。
    • 代码atomicCAS(address, old, new)

atomicCAS是一个非常有用且强大的操作,被称为“通用操作”,因为它可以用于实现各种同步原语。其核心作用是检测当前状态是否被其他线程修改过。如果状态未被修改(当前值等于old),则执行更新(设置为new);如果已被修改(当前值不等于old),则放弃更新,从而避免错误。

应用示例:寻找数组最大值

现在,让我们看看如何使用这些原子操作来解决实际问题。第一个问题是:给定一个数字数组,我们想并行计算其中的最大值。

一个直观的方法是使用atomicMax操作。我们启动与数组元素数量相同的线程,每个线程负责读取一个数组元素,然后尝试使用atomicMax更新一个存储在全局内存中的共享变量global_max

这种方法在功能上是正确的,无论线程如何交错执行,global_max最终都会是数组中的最大值。然而,它的性能会很差,因为所有线程都在竞争同一个全局内存变量global_max。当多个线程对同一变量进行原子操作时,这些操作会被序列化,导致并行性丧失。

为了提升性能,我们需要减少竞争。基本思路是将单一的全局变量拆分成多个副本。

改进后的算法使用一个名为local_max的数组(例如大小为100)来代替单一的global_max。每个线程根据其线程ID对local_max的大小取模,决定更新local_max数组中的哪一个位置。这样,竞争就被分散到了多个内存地址上。

具体流程如下:

  1. 线程首先对local_max中对应的位置执行atomicMax,将读取的数组值val与该位置的当前值old_max比较。
  2. 如果old_max已经大于或等于val,说明val不可能是全局最大值,无需进一步操作。
  3. 只有当old_max小于val时,才需要进一步使用atomicMax更新真正的全局变量global_max

这种方法的优点是:第一次atomicMax操作因为分散到多个地址而更快;第二次对global_maxatomicMax操作虽然仍有竞争,但发生的频率大大降低(仅当本地最大值可能更新全局最大值时才执行)。如果数组值分布随机,这种情况并不频繁。

这个例子的核心在于:当同步瓶颈源于对单一变量的过度竞争时,一个有效的策略就是将该变量拆分。

应用示例:计算直方图

接下来,我们看另一个应用拆分变量策略的例子:计算直方图。

给定一个输入数组,假设其中的值是0到K之间的整数。目标是统计每个值出现的次数。例如,数组[0, 3, 1, 0, 1],K=3,输出直方图为[2, 2, 0, 1](表示0出现2次,1出现2次,2出现0次,3出现1次)。

一个简单的实现方法是:每个线程处理数组的一部分元素(使用跨步循环)。对于每个元素,读取其值v,然后对直方图数组histogram[v]的位置执行atomicAddatomicInc操作。

这个方法同样存在性能问题:所有线程都在竞争更新同一个全局内存中的直方图数组,导致大量竞争和慢速的全局内存访问。

为了改进,我们使用共享内存和拆分策略:

  1. 每个线程块在共享内存中创建自己的本地直方图 local_hist,并初始化为0。
  2. 线程块内的每个线程处理分配给它的数组元素。对于每个正数元素,对local_hist[v]执行原子加操作(现在竞争仅限于线程块内部,且在更快的共享内存上)。
  3. 使用__syncthreads()确保所有线程完成本地直方图的计算。
  4. 最后,由线程块内的部分线程(例如前256个)负责将local_hist中的每个值累加到全局内存的最终直方图global_hist中。这一步虽然仍有全局竞争,但每个位置只被少量线程(每个块一个)更新一次,开销很小。

这种方法的性能提升主要来自两点:大部分原子操作发生在快速的共享内存上;竞争范围从所有线程缩小到单个线程块内部。

应用示例:聚合(过滤)操作

现在,我们来看一个称为“聚合”的问题,其中一个典型例子是过滤操作。

给定一个输入数组source和一个谓词条件(例如“值大于0”),我们需要将所有满足条件的元素复制到一个目标数组destination中。同时,我们需要知道总共复制了多少个元素。

一个简单的全局内存版本算法是:启动与数组元素数量相同的线程。每个线程检查其对应的source元素是否为正数。如果是,则执行atomicAdd获取一个全局偏移量offset,然后将元素写入destination[offset],最后再执行一次atomicAdd来递增全局计数器Nres。这个方法性能很差,因为所有线程都在竞争Nres

我们可以使用共享内存来改进:

  1. 每个线程处理多个元素(M个)。
  2. 在线程块内,使用一个共享变量LN来累计本块内找到的正数元素数量。
  3. 线程块内的线程协作处理元素,更新LN
  4. 由块内的一个“领导”线程(如线程0)使用atomicAddLN累加到全局计数器Nres中,并获取累加前的旧值old_Nres
  5. 每个找到正数元素的线程,需要计算自己在块内的“位置”(通过原子操作递增LN获得)。它写入目标数组的最终位置是 old_Nres + local_position

这种方法将大部分竞争限制在了线程块内部的共享变量LN上,减少了对全局变量Nres的竞争。根据NVIDIA开发者博客的基准测试,共享内存版本的性能显著优于全局内存版本。

该博客还比较了使用CUDA Thrust库中的copy_if函数的性能。copy_if使用前缀和算法实现过滤,其性能特点不同:当过滤出的元素(正数)很少时,由于工作量不足,性能一般;随着正数比例增加,性能会提升,因为能更好地利用GPU资源。

线程束级原子操作与洗牌函数

为了追求极致性能,CUDA提供了更底层的线程束级原子操作洗牌函数。这些操作允许同一个线程束(通常32个线程)内的线程直接、快速地同步和交换数据,速度远快于基于共享内存或全局内存的原子操作。

线程束级操作的核心思想是利用GPU的SIMT(单指令多线程)特性。一个线程束内的线程是“锁步”执行的,这为它们之间的无锁、快速通信提供了可能。

以下是几个关键的洗牌操作(shuffle):

  • __shfl_up_sync: 线程从索引比它小delta的线程获取变量值。
    • 代码__shfl_up_sync(mask, var, delta)
  • __shfl_down_sync: 线程从索引比它大delta的线程获取变量值。
    • 代码__shfl_down_sync(mask, var, delta)
  • __shfl_xor_sync: 线程从索引为 lane_id XOR mask 的线程获取变量值。常用于实现蝶形交换,是高效归约算法的关键。
    • 代码__shfl_xor_sync(mask, var, mask)
  • __shfl_sync: 从指定源线程索引广播变量值到线程束内的所有活动线程。
    • 代码__shfl_sync(mask, var, src_lane)

参数mask是一个32位掩码,用于指定线程束内哪些线程是参与此次同步操作的“活动线程”。这非常重要,因为线程束内的线程可能因为分支而执行不同代码路径。洗牌操作只能从活动线程读取数据。

应用:线程束级归约

利用洗牌函数,我们可以实现非常高效的线程束级归约(如求和)。例如,对线程束内所有线程的某个值v求和:

  1. 首先,每个线程执行 v += __shfl_down_sync(0xffffffff, v, 16) (对于32线程的线程束)。
  2. 然后,执行 v += __shfl_down_sync(0xffffffff, v, 8)
  3. 接着, v += __shfl_down_sync(0xffffffff, v, 4)
  4. 继续, v += __shfl_down_sync(0xffffffff, v, 2)
  5. 最后, v += __shfl_down_sync(0xffffffff, v, 1)

经过这5步(log2(32)步),线程束内第一个线程(或其他指定线程)的v值就是所有线程v值的总和。由于线程束内线程锁步执行,无需显式同步,且所有操作在寄存器间进行,速度极快。

构建分层归约

对于大规模数组的归约,我们可以结合使用线程束级归约和块级、网格级归约:

  1. 线程块级归约:每个线程块包含多个线程束。首先,每个线程束使用洗牌函数完成内部归约,得到一个部分和。然后,由每个线程束的第一个线程将这个部分和写入共享内存的特定位置。最后,再由第一个线程束使用洗牌函数对这些写入共享内存的部分和进行第二次归约,得到整个线程块的总和。
  2. 网格级归约:如果数据量超过一个线程块能处理的范围,则启动多个线程块。每个线程块计算出自己的总和后,将其输出到一个全局数组。然后,再启动一个或多个后续的内核,对这个全局数组中的块级结果进行归约,最终得到整个网格(即整个数据集)的总和。

这种分层归约策略是CUDA高性能并行算法的典型模式,充分利用了线程束级操作的极速和共享内存的带宽,有效减少了全局同步的开销。

总结

本节课我们一起学习了CUDA中的内联函数,特别是用于同步的原子内联函数和用于线程束内高效通信的洗牌函数。

我们首先回顾了GPU编程中的竞态条件问题,并介绍了atomicAddatomicMaxatomicCAS等基本原子操作,它们如同微型的临界区,能保证对单个变量的操作原子性。

接着,我们通过寻找数组最大值计算直方图的例子,学习了当单一变量成为性能瓶颈时,如何通过拆分变量和利用共享内存来减少竞争、提升性能。

然后,我们探讨了聚合(过滤)操作,比较了全局内存、共享内存以及Thrust库copy_if等不同实现方式的性能特点。

最后,我们深入学习了更高级的线程束级编程。利用__shfl系列洗牌函数,线程束内的线程可以在寄存器级别直接交换数据,实现无锁且极其高效的归约等操作。我们掌握了如何利用这些函数构建线程束级归约,并进一步将其组合成线程块级乃至网格级的分层归约算法,以应对大规模数据计算。

理解并合理应用这些内联函数和同步模式,对于编写高性能的CUDA程序至关重要。它们允许我们在保证正确性的前提下,最大限度地挖掘GPU的并行计算潜力。

014:前缀和算法与应用

在本节课中,我们将学习并行计算中的一个核心基础操作:前缀和(Prefix Sum),也称为扫描(Scan)。我们将探讨其定义、多种并行实现算法(包括低效的朴素算法和高效的工作最优算法),并了解其在GPU上的实现细节,如避免存储体冲突。最后,我们将看到前缀和在多个实际问题中的强大应用。


前缀和的定义与重要性

前缀和操作的定义很简单。给定一个包含 n 个数字的输入数组 [x0, x1, ..., x(n-1)],其输出是一个同样长度为 n 的数组,其中第 i 个输出元素是前 i+1 个输入元素的和。

公式表示:

output[i] = sum_{j=0}^{i} x[j]

这种操作被称为“前缀和”,因为每个输出都是输入数组一个前缀(从开头到某个位置)的总和。它也被称为“包含式扫描”(Inclusive Scan),因为第 i 个输出包含了第 i 个输入。

还存在一种“排除式扫描”(Exclusive Scan),其第 i 个输出是前 i 个输入元素的和(不包含第 i 个输入)。

公式表示:

exclusive_output[i] = sum_{j=0}^{i-1} x[j]

虽然前缀和看起来是一个基础操作,但它在并行计算中至关重要,是构建许多其他高效并行算法(如直方图计算、排序、过滤、树算法等)的基石。其重要性甚至曾引发关于在硬件中直接支持前缀和操作的讨论。


朴素并行前缀和算法

上一节我们介绍了前缀和的基本概念。本节中,我们来看看一个直观但并非最优的并行实现方法。

该算法的核心思想是分阶段进行配对相加。假设有 n 个输入元素(为简化,设 n 为2的幂)。算法进行 log₂(n) 个阶段,每个阶段有一个“跨度”(stride)值。

  • 第一阶段:跨度 stride = 1。每个线程(或处理元素)将其当前值与左边相距 stride 位置的值相加(如果存在)。最左边的元素没有左邻居,保持不变。
  • 后续阶段:每个阶段将跨度加倍 (stride = 2, 4, 8, ...)。每个线程将其当前值与左边相距当前 stride 位置的值相加。
  • 终止:经过 log₂(n) 个阶段后,跨度达到 n/2,此时所有元素都包含了其之前所有元素的和,即得到了正确的前缀和。

算法伪代码示意(使用双缓冲避免读写冲突):

for stride = 1, 2, 4, ..., n/2:
    for all i in parallel where i >= stride:
        out[i] = in[i] + in[i - stride]
    swap(in, out) // 交换输入和输出缓冲区

工作复杂度分析:
该算法总共需要 O(n log n) 次加法操作。而最优的串行算法仅需 O(n) 次操作。因此,这个朴素并行算法引入了 log n 倍的额外工作,对于大规模数据(如百万级)来说,效率损失显著(例如20倍慢)。


高效工作最优算法:Brent-Kung

由于朴素算法的额外开销较大,我们需要寻找工作最优(Work-efficient)的并行算法,即总操作次数为 O(n)。Brent-Kong 算法是其中的经典代表。

该算法的灵感来源于高效的并行归约(Reduction)操作。它分为两个阶段:上行扫描(Up-Sweep)和下行扫描(Down-Sweep)。

第一阶段:上行扫描(归约阶段)
此阶段与并行归约树完全相同。从叶子节点开始,逐层向上将值两两相加,最终在根节点得到所有元素的总和。同时,树中许多中间节点也计算出了其对应子树中所有元素的和。

第二阶段:下行扫描(分发阶段)
此阶段将上行扫描中计算出的部分和,正确地分发到需要它们的节点。从根节点开始,携带部分和值向下传播,通过特定的加法规则,确保每个叶子节点最终获得其前缀和。

算法核心操作(以下行扫描为例):

// 假设 stride 初始为 n/2,并逐次减半
for stride = n/2, n/4, ..., 1:
    for all k in parallel where k % (2*stride) == 0:
        temp = left_value
        left_value = right_value
        right_value = temp + right_value

正确性直觉:
每个节点的索引可以表示为二进制。算法确保了节点最终的值,恰好是其二进制表示中所有为 1 的位所对应的、上行扫描中产生的部分和的总和。例如,节点13(二进制1101)的结果,来自第8个元素的部分和(位1000)、第4-7个元素的部分和(位0100)以及其自身的值(位0001)。

Brent-Kong 算法总操作次数为 O(n),实现了工作最优。


排除式扫描与分段扫描

排除式扫描

排除式扫描要求第 i 个输出不包含第 i 个输入。一个简单的方法是先计算包含式扫描,然后每个输出减去自己的输入值。但存在更优雅的直接算法。

该算法同样采用上行扫描和下行扫描结构。关键区别在于下行扫描开始时,将归约树的根节点值设为0,并在下行过程中执行一种“半蝶形”操作,将部分和值广播到正确的子树中,同时确保不包含自身值。

分段扫描

分段扫描是指我们需要对同一个数组中的多个独立段分别进行前缀和计算,且各段长度可能不同。

解决方案:

  1. 标记:使用一个标志数组(Flag Array)标记每个段的起始位置(例如,段首为1,段内其他位置为0)。
  2. 定义新操作:将原始值 (value) 与标志 (flag) 组合成对 (flag, value)。定义一个新的结合操作符
    (f1, x1) ⊙ (f2, x2) = (f1 | f2, (f2 == 0) ? (x1 + x2) : x2)
    
    这个操作符的巧妙之处在于:当 f2 为0时,表示 x2x1 在同一段内,因此相加;当 f2 为1时,表示 x2 是一个新段的开始,因此只保留 x2,不累加前一段的值。
  3. 应用扫描:使用修改后的前缀和算法(如Brent-Kong),但将加法操作替换为这个新的 操作。最终结果中的 value 部分就是正确的分段前缀和。

分段扫描算法也是工作最优的,其时间复杂度与所有段的总长度成线性关系。


GPU实现:处理大规模数据与存储体冲突

处理大规模数据

Brent-Kong 等算法通常设计用于单个线程块(Thread Block)内,能处理的数据大小受限于块的大小(例如,1024个线程最多处理2048个元素)。为了处理百万甚至更大规模的数组,我们需要分块处理。

三步法:

  1. 块内扫描:将输入数组划分为多个块,每个块独立使用前述算法计算其本地前缀和。
  2. 收集与扫描:收集每个块的最后一个元素(即该块所有元素的总和),形成一个更小的“块总和数组”。对这个块总和数组执行一次全局前缀和
  3. 结果修正:将第2步得到的全局前缀和结果(每个块需要添加的偏移量)分发回对应的块,每个线程将自己的本地前缀和结果加上这个块的偏移量,得到最终正确的全局前缀和。

避免共享内存的存储体冲突

GPU的共享内存被划分为多个存储体(通常32个)。当多个线程同时访问同一个存储体时,会发生存储体冲突,导致访问串行化,降低性能。

在前缀和算法的某些阶段,线程的访问模式会导致严重的存储体冲突(例如,当跨度为2的幂时,多个线程会访问同一存储体)。

解决方案:内存填充
通过将数据在共享内存中错位存储,可以消除这种冲突模式。基本思想是为每个数据元素添加一个基于其索引和存储体数量的偏移量(填充)。

填充地址计算示例:

padded_index = original_index + (original_index / num_banks);

例如,在16个存储体的情况下,原始索引19的元素将被存储在地址 19 + (19/16) = 20。这种策略会浪费少量共享内存空间(约 1/num_banks),但能彻底消除访问冲突,显著提升性能。


前缀和的应用实例

前缀和作为一种基础并行原语,其应用非常广泛。以下是几个典型例子:

1. 流压缩

给定一个数组和一个条件,输出所有满足条件的元素。

  • 步骤
    1. 并行为每个元素计算一个“标志”:满足条件为1,否则为0。
    2. 对这个标志数组计算排除式前缀和。结果数组给出了每个满足条件元素在输出数组中的目标位置。
    3. 并行地将所有满足条件的元素根据前缀和结果给出的位置,散射到输出数组中。

2. 并行字符串比较

比较两个字符串的字典序。

  • 步骤
    1. 并行比较两个字符串的每个对应字符,产生一个数组 AS[i] > T[i] 为1,S[i] < T[i] 为-1,相等为0。
    2. 使用流压缩移除 A 中所有为0的元素。
    3. 检查压缩后数组的第一个元素:若为1,则 S > T;若为-1,则 S < T;若数组为空,则 S == T

3. 视线计算

给定地形高度,判断从观察点沿一条线看出去,哪些点可见(未被中间高点遮挡)。

  • 步骤
    1. 沿视线计算每个点相对于观察点的仰角。
    2. 对这个仰角数组计算前缀最大值(将前缀和中的加法替换为 max 操作)。
    3. 并行比较每个点的实际仰角与它的前缀最大值。如果实际仰角小于前缀最大值,则该点被遮挡;否则可见。

总结

本节课中我们一起深入学习了并行计算中的关键操作——前缀和。

  • 我们首先明确了其定义和两种形式(包含式与排除式)。
  • 接着分析了一个直观但低效的朴素并行算法,其工作复杂度为 O(n log n)
  • 然后,我们重点介绍了高效的 Brent-Kong 工作最优算法(O(n)),它通过上行扫描(归约)和下行扫描(分发)两阶段实现。
  • 我们还将算法扩展到排除式扫描和更复杂的分段扫描,后者通过定义巧妙的结合操作符来处理多个独立段。
  • 针对GPU实现,我们探讨了如何通过分块处理大规模数据,以及如何使用内存填充技术来避免共享内存的存储体冲突,从而优化性能。
  • 最后,我们通过流压缩、并行字符串比较和视线计算等实例,展示了前缀和如何作为基础模块来解决各类实际问题。

掌握高效的前缀和实现,是构建更复杂、高性能并行应用的重要一步。

015:稀疏矩阵向量乘法 (SpMV) 🚀

在本节课中,我们将学习GPU上的一个重要应用:稀疏矩阵向量乘法。我们将探讨其重要性、面临的挑战,以及多种针对GPU优化的存储格式和计算内核。

概述

稀疏矩阵向量乘法是许多科学计算和数据分析应用的核心操作。尽管GPU在稀疏操作上并非最优,但其强大的计算能力仍使其成为执行此任务的可行平台。本节课将深入分析SpMV的难点,并介绍多种存储格式(如DIA、ELL、COO、CSR、Hybrid)及其对应的GPU内核实现,以优化内存访问和负载均衡。

为什么SpMV很重要且具有挑战性?

上一节我们介绍了SpMV的基本概念。本节中我们来看看为什么这个操作既重要又难以在GPU上高效执行。

SpMV是许多高级算法的基础构建块,例如:

  • 优化问题中的迭代求解。
  • 线性方程组求解
  • 特征值计算
  • 有限元模拟
  • 网页排名(PageRank) 等数据分析。

SpMV的挑战主要源于其内存受限的特性以及矩阵的不规则性

公式:给定稀疏矩阵 A 和稠密向量 x,计算 y = A * x。对于输出向量 y 的第 i 个元素,其计算为:
y[i] = sum(A[i, j] * x[j]),其中 j 遍历矩阵第 i 行中的所有非零元素。

这意味着对于每个非零元素 A[i, j],我们需要:

  1. 从内存读取 A[i, j]x[j](两次内存访问)。
  2. 执行一次乘法和一次加法(两次计算操作)。

因此,计算与内存访问的比例很低(约1:1),使得性能严重受限于内存带宽,而非GPU的计算能力。

此外,稀疏矩阵的非零元素分布可能极不规则,导致:

  • 非合并内存访问:线程访问不连续的内存地址,浪费带宽。
  • 负载不均衡:不同行的非零元素数量差异巨大,导致线程工作量不均。

稀疏矩阵的存储格式

为了应对上述挑战,人们设计了多种稀疏矩阵存储格式,避免存储零元素,并优化GPU上的访问模式。选择哪种格式取决于矩阵的具体结构。

对角格式 (DIA)

DIA格式适用于非零元素主要集中在少数几条对角线上的矩阵(例如,来自规则网格或有限元方法的矩阵)。

工作原理

  • data 矩阵:每一列存储一条对角线上的非零元素。若对角线长度小于矩阵维度,用占位符(如*)填充。
  • offset 数组:指示 data 矩阵的每一列对应原矩阵的哪条对角线(0表示主对角线,负数表示左下方,正数表示右上方)。

示例
对于一个矩阵,其非零元素主要在偏移量为 -2, 0, 1 的三条对角线上。

  • data 矩阵的列分别存储这三条对角线上的值。
  • offset 数组为 [-2, 0, 1]

ELL格式

ELL格式适用于矩阵每行非零元素数量大致相同的情况。

工作原理

  • data 矩阵:一个 n行 x m列 的矩阵,其中 n 是原矩阵行数,m 是最大非零元数/行。每行从左开始依次存储该行的非零值。
  • indices 矩阵:与 data 矩阵形状相同,存储对应非零值在原矩阵中的列索引。
  • 若某行非零元少于 m,则在 dataindices 中用占位符填充。

为了在GPU上获得合并内存访问,dataindices 矩阵通常按列优先方式存储。

坐标格式 (COO)

COO格式是一种通用格式,不关心非零元素的分布模式。

工作原理
使用三个数组:

  • data:按行优先顺序存储所有非零值。
  • row_indices:存储每个非零值的行索引。
  • col_indices:存储每个非零值的列索引。

其缺点是同一行的行索引会被重复存储,造成一定冗余。

压缩稀疏行格式 (CSR)

CSR是应用最广泛的通用格式之一,它消除了COO格式中的行索引冗余。

工作原理
使用三个数组:

  • data:按行优先顺序存储所有非零值。
  • col_indices:存储每个非零值的列索引。
  • row_ptr:长度为 n+1row_ptr[i] 指向第 i 行非零数据在 datacol_indices 数组中的起始位置。row_ptr[i+1] - row_ptr[i] 即为第 i 行的非零元数量。

代码:获取第 i 行所有非零元素及其列索引的伪代码如下:

for (int j = row_ptr[i]; j < row_ptr[i+1]; j++) {
    value = data[j];
    column = col_indices[j];
    // 使用 value 和 column 进行计算
}

混合格式 (Hybrid ELL/COO)

混合格式结合了ELL和COO的优点,适用于大多数行长度相近,但存在少数超长行的矩阵。

工作原理

  1. 选择一个基准长度 L(例如,平均每行非零元数)。
  2. 用ELL格式存储每行的前 L 个非零元素。
  3. 剩余的超额非零元素用COO格式存储。

这样,对于规整部分能享受ELL的高效,而对于不规则部分则用COO灵活处理。

格式选择指南

以下是选择存储格式的简要指南:

  • 高度结构化(如对角矩阵):使用 DIA 格式。
  • 行长度大致相等:使用 ELL 格式。
  • 高度不规则,通用目的:使用 CSRCOO 格式。
  • 大多数行长度相近,但有少数超长行:使用 Hybrid (ELL/COO) 格式。

SpMV的GPU内核实现

了解了存储格式后,本节我们来看看如何在GPU上为不同格式实现高效的计算内核。

ELL内核

ELL内核假设每行非零元数相近,以实现负载均衡和合并访问。

实现策略

  • 线程分配:每个线程处理一行。
  • 执行模式:所有线程同步循环遍历列。在第 k 次迭代中,所有线程处理各自行在第 k 列的数据。
  • 内存访问:由于 dataindices 按列优先存储,每次迭代中所有线程访问的内存地址是连续的,从而实现完美的内存合并。

核心代码逻辑

int row = threadIdx.x; // 每个线程负责一行
float dot_product = 0;
for (int col = 0; col < max_nz_per_row; col++) {
    int index = col * num_rows + row; // 列优先访问
    if (col < valid_length[row]) { // 检查是否有效数据
        float val = data[index];
        int col_idx = indices[index];
        dot_product += val * x[col_idx];
    }
}
y[row] = dot_product;

CSR标量内核

CSR标量内核是最直观的实现,但性能通常不佳。

实现策略

  • 线程分配:每个线程处理一行。
  • 问题
    1. 负载不均衡:不同行的非零元数可能差异巨大。
    2. 非合并内存访问:不同线程访问的 datacol_indices 元素地址可能相距甚远。

CSR向量内核

CSR向量内核通过改变工作分配方式来改善负载均衡和内存访问。

实现策略

  • 线程分配:一个线程束(Warp,通常32线程) 共同处理一行。
  • 执行模式:线程束中的每个线程处理该行中相隔32的元素(即线程 t 处理索引为 row_ptr[row] + t, row_ptr[row] + t + 32, ... 的元素)。
  • 优势
    1. 合并访问:一个线程束内的线程访问连续的内存地址。
    2. 负载均衡:将负载不均衡从线程束内部转移到了线程束之间。线程束内部的负载是均衡的,这比线程束内部的负载不均衡对性能影响更小。
  • 最后:需要一个线程束内的归约操作,将所有线程的部分和相加,得到该行的最终结果。

核心步骤

  1. 每个线程计算分配给它的部分点积。
  2. 使用规约树(__shfl_down_sync 等指令)在线程束内求和。

COO内核与分段归约

COO内核为每个非零元素分配一个线程,天然具有完美的负载均衡和合并内存访问。

主要挑战
不同线程处理的非零元素可能属于不同的行。计算最终结果时,需要将同一行的非零元素贡献值相加,而不能跨行相加。

解决方案:分段归约
在归约过程中,每次合并操作前,检查参与合并的两个值是否属于同一行(通过比较 row_indices)。只有属于同一行,才进行加法。

伪代码概念

float value = my_value;
int my_row = my_row_index;
for (int stride = 1; stride < warpSize; stride *= 2) {
    float other_val = __shfl_down_sync(mask, value, stride);
    int other_row = __shfl_down_sync(mask, my_row, stride);
    if (other_row == my_row) {
        value += other_val;
    }
    // 更新 my_row? 在归约中,通常保留较低索引线程的行号
}
// 最终,每个“段”(行)的第一个线程持有该行的结果

性能比较与总结

最后,我们通过实际数据来比较不同格式和内核的性能。

结构化矩阵(如对角矩阵)

  • DIAELL 格式表现最佳,因为它们充分利用了矩阵的规则结构。
  • CSR标量内核 性能随矩阵增大而下降(非合并访问加剧)。
  • CSR向量内核 在行较长时表现更好(线程束利用率高)。

非结构化矩阵

  • CSR标量内核 性能最差。
  • COO内核 性能稳定,但与结构无关。
  • CSR向量内核 相比标量版本有显著提升。
  • Hybrid (ELL/COO) 内核 通常能获得最佳性能,因为它结合了ELL的效率和COO的灵活性。

GPU缓存的作用
GPU的L1/L2缓存主要有助于节省带宽(通过广播机制),而非降低延迟。对于某些矩阵(特别是使用Hybrid格式时),启用缓存能带来明显的带宽提升和性能改善。

总结

本节课我们一起学习了GPU上的稀疏矩阵向量乘法。我们首先理解了SpMV作为基础原语的重要性及其面临的内存瓶颈和不规则访问挑战。接着,我们深入探讨了多种稀疏矩阵存储格式(DIA, ELL, COO, CSR, Hybrid),每种格式针对特定矩阵结构进行优化。然后,我们分析了对应的GPU计算内核(ELL, CSR标量/向量, COO),重点关注它们如何通过线程分配策略来改善内存合并与负载均衡。最后,通过性能对比,我们看到了针对矩阵结构选择合适格式和内核的重要性。掌握这些技术,是高效利用GPU处理稀疏计算问题的关键。

016:GPU上的广度优先搜索(BFS)🎯

在本节课中,我们将学习如何在GPU上解决一个图论问题——广度优先搜索(BFS)。我们将探讨BFS的应用、面临的挑战,并深入讲解一种高效的并行BFS算法,该算法通过巧妙的优化来克服GPU上的性能瓶颈。


概述

BFS是一种基础的图遍历算法,它从源节点开始,逐层探索图中的节点。虽然BFS的计算模式不规则,并非GPU的理想任务,但通过设计合适的算法和优化,我们仍然可以在GPU上获得良好的性能。本节课将首先回顾BFS的串行算法,然后逐步构建一个高效的并行GPU版本,重点解决锁竞争、负载不均衡和重复节点等问题。


BFS的应用与挑战

上一节我们介绍了稀疏矩阵向量乘法(SPMV),它同样是一个不规则问题。BFS也是一个重要的不规则问题,在许多领域有广泛应用。

以下是BFS的一些主要应用场景:

  • 寻找连通分量:例如,在社交网络中找出通过朋友关系可以连接的所有人。
  • 路径查找:作为许多其他算法(如最大流算法)的子程序。
  • 矩阵重排序:例如,在SPMV中提到的Cuthill-McKee排序法,其核心就是执行一次BFS,以增加矩阵的规整性。
  • 基准测试:用于衡量超级计算机等系统的性能,特别是内存系统的性能,因为BFS涉及大量随机内存访问,计算量却很小。

我们通常处理的是大型现实世界图,它们具有以下特点:

  • 节点和边的数量达到百万甚至千万级别。
  • 平均度数较低(例如,每个节点平均连接2到96个邻居)。
  • 不同节点的度数差异巨大(存在度很高的“中心”节点)。
  • 图的直径(任意两点间最短路径的最大长度)差异很大,例如社交网络直径小,而道路网络直径大。

本节内容主要基于NVIDIA研究人员Merrill和Grimshaw的论文,该论文提出了许多高效GPU BFS的核心思想,至今仍是该领域研究的基础。


串行BFS算法回顾

在深入并行算法之前,我们先回顾一下在CPU上标准的串行BFS算法。我们假设图以压缩稀疏行(CSR)格式存储。

CSR格式包含两个数组:

  • R数组:长度为V+1(V为节点数),R[i]指向C数组中节点i的邻居列表的起始位置。
  • C数组:按顺序存储所有节点的邻居ID。

串行BFS算法使用一个队列(Q)来管理待访问的节点,并维护一个距离数组dist记录每个节点到源点的跳数。

算法伪代码如下:

for all nodes v: dist[v] = INFINITY
dist[source] = 0
Q.enqueue(source)

while Q is not empty:
    u = Q.dequeue()
    start = R[u]
    end = R[u+1]
    for idx from start to end-1:
        v = C[idx]
        if dist[v] == INFINITY:
            dist[v] = dist[u] + 1
            Q.enqueue(v)

该算法会访问每条边一次,时间复杂度为 O(V + E)


简单的并行BFS尝试及其问题

本节中我们来看看如何将串行算法并行化。一个直观的想法是让多个线程并行处理队列中的节点。

一个早期的低效并行方法是运行D轮(D为图直径),在每轮中检查所有节点,标记当前距离为i的节点的未访问邻居。这种方法复杂度为 O(V * E),对于大直径图效率极低。

我们希望找到一个总工作量与串行算法相同(O(V+E))的并行算法。一个直接的并行化思路是:

  1. 并行初始化距离数组。
  2. 使用一个带锁的队列,线程在出队和入队时需加锁,以避免并发访问导致数据错误。
  3. 并行处理当前队列中的所有节点,检查其未访问的邻居并将其加入队列(入队时仍需加锁)。

这个方法存在两个主要性能瓶颈:

  1. 锁竞争:对队列的加锁操作非常昂贵,严重限制了并行性。
  2. 负载不均衡:不同节点的邻居数量差异巨大,导致处理不同节点的线程运行时间长短不一。

接下来,我们将着手解决这两个问题。


优化一:消除锁竞争

为了消除昂贵的锁操作,我们不再使用一个需要并发访问的队列。取而代之的是,我们维护两个节点集合(可以看作数组):

  • 当前前沿(Current Frontier):当前BFS层待处理的节点。
  • 下一前沿(Next Frontier):下一BFS层将要处理的节点。

每一层BFS在一个独立的GPU内核(Kernel)中处理。处理完一层后,交换两个前沿的角色,复用内存。

现在,每个线程可以并行地将其节点邻居加入下一前沿数组。但这里有两个新问题:

  1. 需要判断邻居节点是否已被访问过,避免重复加入。
  2. 多个线程可能尝试加入同一个未访问节点,导致下一前沿中出现重复项。

我们先解决如何无锁地分配写入位置。假设每个节点in_i个邻居待加入。我们可以为每个线程预留n_i个槽位。

具体方法是:

  1. 每个线程计算自己需要多少槽位(即未访问邻居的数量)。
  2. 对这些数量执行一次前缀和(Exclusive Scan)运算。
  3. 前缀和的结果给出了每个线程在下一前沿数组中写入的起始偏移量。

这样,每个线程都能无冲突地并行写入自己负责的邻居节点,完全避免了锁的使用。核心思想是利用前缀和进行全局协调。


优化二:改进负载均衡

解决了锁的问题,我们再来看看负载均衡。由于节点度数差异大,让一个线程处理一个节点会导致严重负载不均。

我们的策略是根据邻居数量动态分配计算资源:

  • 少量邻居:由单个线程处理。
  • 中等数量邻居:分配一个线程束(Warp, 32个线程)协作处理。
  • 大量邻居:分配一个线程块(Thread Block)协作处理。

关键是如何让一个Warp或Block内的线程从处理各自节点转为协作处理同一个节点?这里采用一种“投票”机制:

  • 线程将自己的节点ID写入共享内存的同一位置。
  • 最后完成写入的线程“获胜”,其节点ID被所有线程读取。
  • 整个Warp或Block的线程转而共同处理这个获胜节点的邻居。

通过这种方式,我们将计算资源动态分配给需要更多工作的节点,缓解了负载不均衡问题。


优化三:高效的状态检查与去重

在将邻居加入下一前沿前,必须检查它是否已被访问。为每个节点维护一个访问标志会导致大量内存访问。

高效的状态检查(位掩码+标签):

  • 位掩码(Bitmask):一个32位整数可以表示32个节点的访问状态(每位代表一个节点)。首先检查位掩码。
  • 标签(Label):每个节点有一个独立的访问标志。
  • 检查流程:先查位掩码对应位。若为1,则节点已访问。若为0,则不能确定(可能因并发写入被覆盖),需进一步查询该节点的独立标签。
  • 优势:大多数检查只需访问紧凑的位掩码,节省内存流量。论文实验表明,此方法对低直径图尤其有效。

重复节点消除(Culling):
允许多个线程写入同一节点会导致下一前沿中出现重复项,且重复数量可能随层数指数增长,造成大量冗余计算。

去重方法(基于哈希表):

  • Warp Culling:每个Warp在共享内存中维护一个小哈希表(如128项)。插入节点时,计算哈希值。
    • 若槽位为空,插入节点并加入队列。
    • 若槽位已有相同节点,说明是重复,不加入。
    • 若槽位有不同节点(哈希冲突),为安全起见,仍加入队列。
  • History Culling:整个线程块利用L1缓存作为共享哈希表进行去重。
  • 效果:即使哈希表很小,也能消除大部分重复。实验显示,平均可减少约30%的冗余节点。结合位掩码检查,效果更佳。

完整的GPU BFS算法整合

现在,我们将所有优化整合起来,形成完整的GPU BFS算法。

算法框架:
每层BFS由一个独立的内核计算。内核的输入是可能包含重复的当前前沿

内核内的步骤:

  1. 去重(可选):线程使用Warp/History Culling判断其分配到的顶点是否重复,只处理唯一顶点。
  2. 邻居收集与负载均衡:线程读取其节点的邻居列表。根据邻居数量,可能召唤整个Warp或Block来协作。
  3. 状态检查:对于每个待加入的邻居,使用位掩码+标签的方法检查是否已被访问。
  4. 槽位预留:每个未访问的邻居对应一个待预留的队列槽位。线程块内对所有需要预留的槽位数进行前缀和,得到块内偏移。
  5. 全局分配:线程块派一个代表,原子性地将本块所需总槽位数加到全局队列大小计数器上,并取回旧的全局偏移量。
  6. 并行写入:每个线程根据块内偏移和全局偏移,计算出最终写入位置,将邻居节点无冲突地写入下一前沿数组。

算法变体:
论文还探讨了不同执行顺序的变体,如收缩-扩展(先对当前层去重,再扩展)、扩展-收缩两阶段(扩展和收缩分两个内核)以及混合策略。不同变体在不同特征的图(如前沿大小、直径)上性能各异。

性能结果:
整合了所有优化的算法相比8核CPU实现了显著的加速(通常10倍以上,最高可达30倍),证明了在GPU上高效执行BFS的可行性。


多GPU扩展的挑战

最后,我们简要探讨将算法扩展到多GPU的尝试。早期研究通过PCIe连接多个GPU,采用轮询方式将顶点划分到不同GPU。

多GPU算法流程:

  1. 每轮BFS,每个GPU在本地计算下一前沿
  2. 由于下一前沿中的节点可能属于其他GPU,需要根据节点ID将其排序到对应的“桶”中。
  3. 进行全局同步(Barrier)。
  4. 每个GPU从其他GPU的对应“桶”中取回属于自己的节点,形成自己下一轮的当前前沿

挑战与结果:

  • 频繁的全局同步和数据交换开销巨大。
  • 对于高直径图(如道路网络,有2万层),需要同步上万次,性能急剧下降。
  • 实验表明,仅在低直径、高平均度数的图上能获得有限加速,平均而言多GPU性能反而不如单GPU。这凸显了不规则通信模式下的扩展性挑战。

总结

本节课中我们一起学习了如何在GPU上实现高效的广度优先搜索。我们从串行算法出发,逐步解决了并行化中的锁竞争、负载不均衡和重复项处理等核心挑战。关键优化技术包括:使用双缓冲区和前缀和消除锁、基于位掩码和标签的高效状态检查、利用小哈希表进行去重,以及动态的Warp/Block级负载均衡。尽管将算法扩展到多GPU仍面临通信开销的严峻挑战,但单GPU上的优化算法已能取得令人瞩目的性能提升。BFS在GPU上的优化仍然是并行计算中一个活跃且富有价值的研究领域。

017:并行算法设计第一部分

在本节课中,我们将开始学习并行算法的设计过程。我们将重点介绍一种名为 Foster设计方法学 的系统化思想流程,它特别适用于设计可扩展的分布式内存并行算法。这类似于设计串行算法时使用的分治法或贪心算法等技巧。该方法鼓励在设计过程的最后阶段才考虑具体的硬件细节,从而专注于算法本身的抽象设计。

概述:Foster设计方法学四步法

Foster方法学由Ian Foster提出,包含四个核心步骤:

  1. 划分:将大问题分解为多个可并行执行的小任务。
  2. 通信:确定这些任务之间需要交换哪些数据。
  3. 聚合:将多个小任务组合成更大的“超级任务”,以减少通信开销。
  4. 映射:将聚合后的任务分配到实际的物理处理器上执行。

接下来,我们将详细探讨每个步骤,并应用该方法来设计Floyd-Warshall全源最短路径算法的并行版本。我们还将讨论两种主要的划分策略:数据划分算法划分

第一步:划分

划分的目标是将一个大型应用分解为多个可以并行执行的小型任务。有两种主要的划分方式。

数据划分

在数据划分中,我们将应用程序处理的数据集分割成多个部分,然后创建任务来分别处理这些数据部分。这有时也被称为域分解

例如,假设我们有一个三维数据立方体,需要在每个坐标点(图中黑点)进行计算。

我们可以用不同方式将这些数据点分组为任务:

  • 一维划分:将整个立方体视为几个大面,每个面作为一个任务。这产生的任务数量较少。
  • 二维划分:将立方体中的每一行数据点作为一个任务。这会产生中等数量的任务。
  • 三维划分:将每个单独的数据点(即每次计算)作为一个任务。这会产生大量细粒度的任务。

在数据划分中,你从所有计算开始,然后决定如何将它们组合成原始任务。组合大量数据会得到类似一维划分的大任务,而组合少量数据则会得到更细粒度的二维或三维划分。

算法划分

在算法划分中,我们不划分数据,而是将算法本身分解为多个可以并行执行的子任务。这有时被称为功能分解

例如,在一个处理MRI图像的应用中,整体算法可以分解为多个子任务:采集图像配准图像追踪器械位置确定图像位置显示图像。这些子任务之间存在依赖关系(例如,必须先采集图像才能进行配准)。

这种划分形成了一个流水线。每个患者(数据)都需要依次经过流水线的各个阶段。通过让多个患者同时处于流水线的不同阶段,我们可以获得并行性。

划分的目标与考量

在进行划分时,我们需要考虑以下几点:

  • 足够的任务数量:通常希望任务数量是处理器数量的10倍左右。这样在将任务映射到处理器时,有更大的灵活性来应对负载不均衡等问题。
  • 权衡冗余计算与通信:有时,让两个任务都计算某个值(冗余计算)比让一个计算后发送给另一个(需要通信)更快。但需要谨慎使用,避免过多的冗余计算或数据复制。
  • 任务规模均衡:尽量使各个任务的计算量相近,以利于负载均衡。
  • 可扩展性:当问题规模增大时,应能生成更多的任务,以便利用更多的处理器。

上一节我们介绍了如何将问题分解为任务,接下来我们需要看看这些任务之间如何协作。

第二步:通信

通信步骤旨在确定不同任务之间需要交换哪些数据。任务(用圆圈表示)之间通过边进行通信。

通信主要分为两种类型:

  • 局部通信:一个任务只需要与少数其他任务交换数据,对应于MPI中的点对点通信。
  • 全局通信:一个任务需要来自所有(或许多)其他任务的数据,对应于MPI中的集合通信(如广播、规约)。

理想的通信模式应具备以下特点:

  • 通信负载均衡:避免某个任务承担远多于其他任务的通信量。
  • 通信最小化:理想情况下,每个任务只与少量“邻近”的任务通信。
  • 通信并发性:通信应能同时进行,避免所有通信都经过同一条链路造成顺序瓶颈
  • 计算与通信重叠:尽可能让通信进行的同时,处理器能执行其他计算,从而充分利用所有资源。

在确定了任务间的通信模式后,我们可能会发现通信开销过大。接下来,我们将通过聚合来优化这一点。

第三步:聚合

聚合是指将多个小任务组合成更大的“超级任务”。这样做的目的是减少任务间的通信开销。

例如:

  • 如果将两个需要频繁通信的小任务合并为一个超级任务,那么它们之间的通信就变成了同一处理器内的内存访问,无需经过网络,性能大幅提升。
  • 如果原本需要发送两条消息,在聚合后可以合并为一条包含更多信息的消息发送,从而减少消息传递的固定开销(如包头、路由等)。

聚合的目标包括:

  • 提高局部性:减少需要与远程处理器通信的需求,更多计算在本地完成。
  • 用计算换通信:在合理范围内,可以用少量的冗余计算来避免昂贵的通信。
  • 维持负载均衡:聚合后的超级任务之间,其计算成本和通信成本应保持相近。
  • 匹配目标系统:聚合后超级任务的数量应至少等于目标系统的处理器数量,以确保能利用所有处理器。
  • 代码转换便利性:从串行代码并行化时,应避免对原始代码进行过于剧烈的改动。

完成聚合后,我们得到了一个由超级任务组成的计算图。最后一步就是将这些任务分配到具体的硬件上运行。

第四步:映射

映射是将聚合后的超级任务分配到物理处理器上执行的过程。我们的目标有两个:

  1. 负载均衡:平衡每个处理器上的工作量。
  2. 通信最小化:最小化处理器间的通信量。

然而,这两个目标常常是冲突的。例如,将所有任务放在一个处理器上可以完全消除处理器间通信,但会导致严重的负载不均衡。反之,将任务均匀分散到所有处理器上有利于负载均衡,但可能增加通信。

任务分配策略

任务分配主要有两种方式:

  • 静态分配:在计算开始前就确定任务到处理器的映射。
    • 如果任务计算时间相同,可以简单地按顺序或轮询方式分配。
    • 如果任务计算时间不同,可以采用轮询调度等方法,使每个处理器获得一组多样化的任务,以期总负载接近。
  • 动态分配:在运行时动态分配任务。例如工作窃取策略:
    • 每个处理器维护一个任务队列。当某个处理器快速完成自己队列中的所有任务后,它可以从其他负载较重的处理器的队列中“窃取”任务来执行。
    • 这种方式能更好地应对任务大小未知或动态产生的情况,但引入了任务调度管理的开销。

现在,我们已经了解了Foster方法学的四个步骤。接下来,让我们通过一个具体例子来实践整个设计流程。

实例应用:Floyd-Warshall全源最短路径算法

我们将使用Foster方法学来设计Floyd-Warshall算法的并行版本。该算法用于求解加权有向图中所有节点对之间的最短路径。

算法串行形式回顾

算法使用邻接矩阵 D 作为输入和输出。通过动态规划求解,其核心递推公式为:

D[i][j]^(k) = min( D[i][j]^(k-1), D[i][k]^(k-1) + D[k][j]^(k-1) )

其中 D[i][j]^(k) 表示只允许使用节点 1k 作为中间节点时,从 ij 的最短路径距离。

串行算法是一个三层嵌套循环:

for k from 0 to n-1:
    for i from 0 to n-1:
        for j from 0 to n-1:
            D[i][j] = min(D[i][j], D[i][k] + D[k][j])

并行化设计

1. 划分

我们专注于外层 k 循环的每一次迭代(一个阶段)。在每个 k 阶段,需要计算所有 ij 对应的 D[i][j]关键观察是:在固定的 k 阶段,对于不同的 (i, j),其计算 D[i][j] = min(D[i][j], D[i][k] + D[k][j]) 是相互独立的。因为该阶段用到的 D[i][k]D[k][j] 值来自上一阶段 (k-1),在本阶段计算中保持不变。
因此,我们可以为每个 (i, j) 对创建一个任务。对于 n 个节点,每个 k 阶段有 n^2 个任务,它们可以完全并行执行。不同 k 阶段之间是顺序依赖的。

2. 通信

k 阶段,任务 T(i,j) 需要 D[i][k]D[k][j] 的值。

  • 对于固定的 i,所有 j 不同的任务 T(i,*) 都需要同一个值 D[i][k]
  • 对于固定的 j,所有 i 不同的任务 T(*,j) 都需要同一个值 D[k][j]
    因此,通信模式是:将第 k 行的所有值广播给所有列,将第 k 列的所有值广播给所有行。

3. 聚合与映射

我们选择按行聚合:将矩阵的若干连续行分配给一个处理器(一个MPI进程)。假设有 p 个处理器,矩阵大小为 n x n,则每个处理器获得大约 n/p 行。

  • 优势:在按行聚合后,行广播(即 D[i][k] 的广播)变得不需要了,因为所需的值已经位于负责该行的处理器内部。我们只需要进行列广播(即 D[k][j] 的广播)。
  • 映射:每个聚合后的超级任务(一组行)映射到一个处理器上。

4. 并行算法伪代码(核心部分)

假设 part 是每个处理器本地存储的部分矩阵,s = n/p 是每个处理器的行数。

for k = 0 to n-1:
    // 1. 确定拥有第k行的根处理器
    root = floor(k / s)
    // 2. 根处理器准备并广播第k行
    if my_rank == root:
        offset = k % s
        broadcast_buffer = part[offset][0..n-1] // 复制第k行本地部分
    MPI_Bcast(broadcast_buffer, n, root, comm) // 所有进程参与广播
    // 3. 每个处理器并行计算自己负责的行
    for i_local in my local rows (0 to s-1):
        for j from 0 to n-1:
            part[i_local][j] = min(part[i_local][j],
                                   part[i_local][k] + broadcast_buffer[j])

5. 性能分析

  • 串行时间T_seq = O(n^3)
  • 并行时间(每阶段)
    • 计算:每个处理器计算 O(n^2 / p) 个元素。
    • 通信:广播一个大小为 n 的数组,耗时 O(log p + n)(使用树形广播)或 O(n)(简单广播)。
  • 总并行时间T_par = O( n * (n^2/p + n) ) = O(n^3/p + n^2)
  • 加速比:当 n 较大时,T_par ≈ O(n^3/p),加速比接近 p,即线性加速比,表明该并行算法非常高效。

通过这个例子,我们完整实践了Foster设计方法学的四个步骤。最后,我们再简要看两个数据划分的简单例子,以对比通信开销的影响。

简单数据划分示例对比

示例1:列表求和(通信密集型)

问题:求 n 个数的和。
简单并行策略

  1. 主进程将 n 个数划分为 p 块。
  2. 将每块数据散播给一个从进程。
  3. 每个从进程计算本地和。
  4. 从进程将本地和规约到主进程。
    性能瓶颈:第一步的数据散播需要主进程发送 O(n) 数据,通信时间为 O(n)。这使得总并行时间至少为 O(n),而串行时间也是 O(n),因此无法获得加速

示例2:数值积分(计算密集型)

问题:使用梯形法则计算函数 f(x) 在区间 [a, b] 上的积分近似值。
并行策略

  1. 将区间 [a, b] 等分为 n 个子区间。
  2. 每个进程根据其秩,自动确定自己负责哪 n/p 个子区间,无需主进程分发数据(因为区间公式已知)。
  3. 每个进程独立计算所负责子区间的局部面积和。
  4. 将所有进程的局部和规约到主进程。
    性能优势:避免了初始的数据分发通信。每个进程的计算时间为 O(n/p),最后的规约时间为 O(log p)O(p)。总并行时间 T_par = O(n/p + p)。当 n >> p 时,T_par ≈ O(n/p),相比串行时间 O(n),获得了近似的线性加速比。

总结

本节课中,我们一起学习了并行算法设计的核心方法——Foster设计方法学。该方法通过划分、通信、聚合、映射四个系统化的步骤,引导我们设计出可扩展的并行算法。我们详细探讨了数据划分与算法划分两种策略,以及通信、聚合和映射过程中的关键考量因素,如负载均衡、通信最小化和局部性优化。

通过将该方法应用于 Floyd-Warshall全源最短路径算法,我们实践了完整的并行化设计流程,并得到了一个具有线性加速比的高效并行算法。最后,通过对比列表求和与数值积分两个简单例子,我们强调了减少初始通信开销对于获得良好并行性能的重要性。

掌握这一设计方法学,将为你应对各种复杂的并行计算问题提供有力的工具和清晰的思路。

018:并行算法设计(第二部分)

在本节课中,我们将要学习基于分治法的数据划分策略。上一节我们介绍了简单的数据划分,本节中我们来看看分治法划分,并探讨其在并行计算中的应用,特别是如何通过递归和任务树结构来实现高效的并行执行。

分治法划分 vs. 简单数据划分

简单数据划分将数据一次性分割并分配给处理器。分治法划分则不同,它会根据数据规模持续递归地分割数据,直到子问题足够小可以直接求解。

算法上,我们可以这样描述:

  1. 检查问题规模。
  2. 如果问题规模过大,则将其划分为两个子问题(part0part1)。
  3. 并行或递归地求解每个子问题。
  4. 将子问题的解合并,得到原问题的解。
  5. 如果问题规模足够小,则直接求解。

这个过程可以表示为一棵树。根节点是原始问题,如果规模过大,则分裂为子节点(子问题)。这个过程持续进行,直到叶子节点代表可以直接求解的小任务。

分治法求和示例

以并行求和为例。简单划分是将数字列表平均分成P份。分治法则会持续将列表对半分割,直到每个子列表只剩一或两个数字,然后直接相加,最后将结果逐层合并。

这个过程可以并行化:

  • 分割阶段:初始问题分割后,子问题是独立的,可以并行地进一步分割。
  • 求解阶段:所有叶子节点的小任务可以并行求解。
  • 合并阶段:子问题的解可以并行地向上合并。

分治法天然适合并行化表述。由于分割次数未知,用迭代方式编写程序较为繁琐,因此通常采用递归方式实现。

处理器分配与任务树

任务可以表示为树结构。我们需要将树中的任务分配给一组处理器,并尝试在树的不同层级复用处理器。

具体到求和例子,假设有8个处理器(P0-P7):

  1. 初始列表在处理器P0上。
  2. P0将列表对半分割,前半部分自己保留(或分配给P0-P3中的第一个可用处理器),后半部分分配给处理器P4-P7中的第一个(例如P4)。
  3. 类似地,P4如果仍需分割其数据,则将其处理器组(P4-P7)对半,将子问题分配给P4-P5和P6-P7。
  4. 此规则始终适用:一个处理器组处理一个问题时,若需分割,则将问题与可用处理器组同时对半分配。

分割阶段可以并行,因为P0和P4在分割自己的数据时无需相互通信。底层的小任务求解也是并行的。

合并阶段与递归实现

合并阶段同样并行。子问题的解需要合并回其父进程。例如,P0分割任务给P1后,最终P1的结果会发送回P0进行合并。最终,所有结果会汇总回根处理器P0。

以下是基于MPI伪代码的递归实现核心思路:

关键点1:确定数据来源
每个进程需要知道从哪个进程接收数据。可以通过进程号的二进制表示来判定:关闭最右侧的置位比特。例如,进程6(110)最右侧置位比特是010(值2),关闭后得到100(值4),即它应从进程4接收数据。
代码表示为:receive_from = my_rank & (my_rank - 1)

关键点2:确定接收数据量
接收的数据量由最右侧置位比特对应的数值决定。例如,进程4(100)最右侧置位比特值为4,因此接收4个数据。进程6(110)最右侧置位比特值为2,因此接收2个数据。
代码表示为:receive_count = my_rank & (-my_rank) (利用二进制补码性质)

递归函数框架

  1. 进程0拥有全部数据,调用递归加法函数。
  2. 其他进程首先根据上述规则接收数据。
  3. 每个进程(包括进程0)在递归函数中:
    • 如果数据量count大于阈值(如2),则将数据对半分割。将后半部分发送给进程 my_rank + count/2,并递归处理前半部分。然后接收来自 my_rank + count/2 的部分和,将其与自己的部分和相加。
    • 如果数据量足够小(如1或2),则直接计算并返回和。
  4. 非0进程在计算完成后,将结果发送给其数据来源进程。

一般情况与算法效率

通常处理器数(P)远小于数据量(N)。此时,递归分割持续到某个子问题仅由一个处理器负责为止,然后该处理器串行解决该子问题。最终效果是每个处理器获得大约 N/P 个数据。

算法效率分析(假设N和P均为2的幂):

  • 分割阶段:共 log P 步。进程0发送的数据总量构成几何级数,总和约为 N - N/P。该阶段时间包含 log P 次通信和计算。
  • 计算阶段:每个处理器计算 N/P 个数据的和,时间为 O(N/P)
  • 合并阶段:共 log P 步,每步只传递一个部分和,通信量为 O(1)。该阶段时间为 O(log P)

总运行时间大致为:O(N) + O(log P) + O(N/P)
对于求和操作,串行时间也是 O(N),因此此并行分治算法并未获得加速。但这不意味着分治并行无用。

分治法的优势:自适应积分

分治法划分在自适应积分等场景中具有优势。当被积函数在某些区域变化剧烈,而在其他区域平缓时,均匀划分会导致误差不均。

分治法允许根据局部误差动态调整划分粒度:

  1. 处理器负责一个积分区间。
  2. 它用梯形法则估算积分,并计算近似误差(例如,利用中点构造的三角形面积)。
  3. 如果误差超过阈值,则将该区间对半分割,并分配给两个子处理器(或递归处理)。
  4. 如果误差可接受,则停止分割。

这样,在函数变化剧烈的区域会产生更深的递归和更细的划分,而在平缓区域划分较粗。这形成了一棵非完全二叉树,能有效提高整体精度。如何为这种不平衡的任务树高效分配处理器,是一个任务调度问题。

从数据划分到算法划分

接下来,我们从数据划分转向算法划分。算法划分不是拆分数据,而是将算法本身分解成多个阶段,让不同阶段轮流处理数据流。

流水线计算

一个典型的算法划分例子是流水线计算。将计算任务分解为一系列顺序执行的阶段(任务),数据项依次流过这些阶段。多个处理器分别负责不同阶段,形成流水线。

流水线可行的三种情况:

  1. 多问题实例:有多个独立的问题需要处理,每个问题都需要经过流水线的所有阶段。
  2. 多数据项:有一个数据流需要处理,每个数据项都需要经过流水线的所有阶段。
  3. 重叠执行:前一阶段尚未完成全部计算,就可以将中间结果传递给下一阶段,使其提前开始。

流水线类型与性能

类型1:多实例流水线
设有P个阶段,M个问题实例。总执行周期数为 M + P - 1。第一个实例需要 P-1 个周期到达最后阶段,之后每个周期完成一个实例。平均每个实例的处理时间随着M增大而趋近于常数,相比串行处理每个实例需O(P)时间,获得了加速。

类型2:多数据项流水线
设有P个阶段,N个数据项。总执行周期数同样为 N + P - 1。每个周期时间 T_cycle = T_comp + T_comm

示例:流水线插入排序
使用N个处理器对N个数排序。每个处理器保存当前遇到的最大值。数据依次流过处理器:

  • 若收到值 > 本地保存值,则替换本地值,并将原保存值向后传递。
  • 若收到值 <= 本地保存值,则直接向后传递。
    经过 2N-1 个周期后,所有处理器中保存的值即为排序结果。并行时间为 O(N),而串行插入排序时间为 O(N^2),获得了线性加速。

类型3:重叠执行流水线
示例:回代法求解上三角线性方程组
求解 Ax = b,其中A是上三角矩阵。有n个未知数,使用n个处理器。

  • P0 直接计算 x0 = b0 / a00,然后发送给P1。
  • P1 一收到 x0 就立即转发给 P2,然后利用 x0 计算 x1,计算完成后发送 x1 给 P2。
  • P2 一收到 x0(来自P1)就转发给 P3,收到 x1 后也转发给 P3,然后利用 x0x1 计算 x2,以此类推。

每个处理器 Pi 在收到所需的部分解时立即转发,使后续处理器能尽早开始计算。虽然处理器 Pn-1 的计算和通信量是 O(n),但由于流水线重叠,总完成时间仍是 O(n),相比串行回代的 O(n^2),获得了线性加速。

总结

本节课中我们一起学习了:

  1. 分治法数据划分:通过递归分割问题直至可直接求解,利用任务树实现并行。虽然对求和等简单操作可能不带来加速,但其在自适应积分等非均匀问题中非常有效。
  2. 算法划分与流水线计算:将算法分解为顺序阶段,数据流经各阶段处理。我们分析了三种流水线类型(多实例、多数据项、重叠执行),并通过幂级数计算、插入排序和求解上三角方程组等实例,展示了流水线如何利用并行性获得加速。

下一讲,我们将探讨在共享内存设置下的并行算法设计。

019:循环并行化 🌀

在本节课中,我们将学习如何在共享内存环境下设计并行算法,核心是掌握循环并行化的方法。我们将从任务分解和依赖分析入手,逐步介绍如何识别独立任务、处理依赖关系,并最终使用OpenMP实现高效的并行循环。


概述:共享内存算法设计方法论

上一讲我们讨论了分布式内存环境下的Foster设计方法学。本节中,我们将介绍在共享内存环境下设计算法的类似方法。

共享内存程序中的并行性大多发生在循环内部。因此,要设计高效的共享内存算法,我们需要专注于如何高效地并行化循环。

共享内存算法的设计过程包含以下步骤:

  1. 任务分解:将原始问题分解为多个子任务。
  2. 依赖分析:识别任务之间的依赖关系,目标是让任务尽可能独立,以减少线程间昂贵的同步操作。
  3. 任务映射:将独立的任务映射到不同的线程上执行,并注意负载均衡等问题。
  4. 程序实现:使用OpenMP等工具将设计转化为可运行的程序。

在设计过程中,我们需要考虑以下几点:

  • 依赖分析:确定程序语句间的依赖关系,这是形成独立任务的基础。
  • 负载均衡:例如,使用OpenMP中的静态或动态工作分配,或块/循环分配。
  • 变量作用域:正确指定共享变量和私有变量,以确保正确性和效率。尽可能使用私有变量以避免冲突。
  • 归约操作:对于涉及同步的集体操作(如求和),使用归约变量比保护共享变量更高效。
  • 数据结构访问:对于矩阵等数据结构,考虑按行或按列映射,这会影响缓存使用和性能。

我们的首要目标是找出可以创建的独立任务。为此,我们将使用一种称为数据依赖分析的技术。


数据依赖分析 🔍

数据依赖分析技术用于确定程序中的语句是否可以以不同的顺序执行。

假设在一个顺序程序中,有两个语句S1和S2,且S1在S2之前执行。

  • 如果语句独立,那么我们可以交换它们的执行顺序(先S2后S1),程序的输出和结果保持不变。
  • 如果语句存在依赖,则必须保持S1在S2之前执行。如果交换顺序,可能导致程序产生不同的结果。

因此,任何并行化或程序变换都必须保证与原始顺序程序具有相同的输出。这意味着,在并行化时,我们必须确保存在依赖关系的语句以相同的顺序执行。

依赖类型

语句之间存在三种基本类型的依赖:

  1. 写后读依赖:也称为真依赖。记作 S1 ->T S2。
    • 含义:S1写入一个内存位置,随后S2读取该位置。
    • 原因:S2读取的值依赖于S1写入的值。交换顺序会导致S2读取到旧值或未定义值。
  2. 读后写依赖:也称为反依赖。记作 S1 ->A S2。
    • 含义:S1读取一个内存位置,随后S2写入该位置。
    • 原因:如果先执行S2写入,S1随后会读取到被S2修改后的新值,而非原始值。
  3. 写后写依赖:也称为输出依赖。记作 S1 ->O S2。
    • 含义:S1和S2都写入同一个内存位置。
    • 原因:必须保持写入顺序,因为最终该位置的值由后执行的语句决定。

如果两个语句之间不存在以上任何一种依赖,则称它们为独立语句,可以任意交换执行顺序。

依赖关系示例

考虑以下包含五个语句的程序:

S1: x = 2
S2: z = x + y
S3: y = x
S4: x = z
S5: z = 5

假设所有变量初始值为0。

以下是存在的依赖关系:

  • 真依赖 (S1 ->T S3): S1写入x,S3读取x
  • 真依赖 (S2 ->T S4): S2写入z,S4读取z(和x)。
  • 反依赖 (S4 ->A S5): S4读取z,S5写入z
  • 输出依赖 (S2 ->O S5): S2和S5都写入z

依赖关系的性质

依赖关系具有传递性。如果S2依赖于S1,且S3依赖于S2,那么S3也依赖于S1。这意味着在执行时,必须保持S1、S2、S3的顺序。

一个重要的推论是:存在依赖关系的语句不能在不同的处理器上运行。因为如果没有昂贵的同步机制,无法保证它们在不同速度的处理器上仍按正确顺序执行。因此,依赖语句必须被分配到同一个处理器上。

为了获得并行性,我们的目标是找到尽可能多的独立语句。在共享内存程序中,大部分计算发生在循环中,因此我们将重点放在并行化循环上。


循环依赖分析 🔄

为了分析循环,我们引入记号 S[i]S[i][j],表示语句S在循环迭代i(或嵌套迭代i, j)中的执行实例。

循环中的依赖分为两类:

  1. 循环携带依赖:依赖关系跨越不同的循环迭代。例如,后一次迭代依赖于前一次迭代的计算结果。
  2. 循环无关依赖:依赖关系发生在同一次循环迭代内部的不同语句之间。

循环依赖示例

示例1:

for (i=1; i<n; i++) {
    S1: a[i] = a[i-1] + b[i]; // 写a[i]
    S2: c[i] = a[i] + d[i];   // 读a[i]
}
  • 循环携带依赖 (真依赖): S1[i] ->T S1[i+1]。因为S1[i]写入a[i],而S1[i+1]读取a[i](计算a[i+1-1])。
  • 循环无关依赖 (真依赖): S1[i] ->T S2[i]。因为S1[i]写入a[i]S2[i]读取a[i]

示例2:

for (i=0; i<n; i++)
    for (j=1; j<n; j++)
        S3: a[i][j] = a[i][j-1] + 1;
  • 循环携带依赖 (在j循环): S3[i][j] ->T S3[i][j+1]。因为写入a[i][j],随后在j+1迭代时读取a[i][j]
  • 无循环携带依赖 (在i循环): 对于固定的j,不同i迭代的S3语句访问不同的a[i][j],互不依赖。

依赖分析工具:ITG 与 LDG 📊

为了辅助依赖分析,我们引入两种图表示法。

迭代遍历图

迭代遍历图 展示了循环所有迭代的执行顺序。图中每个节点代表一个特定的迭代(如(i, j)),边表示迭代执行的先后顺序(通常是循环嵌套定义的顺序)。

例如,对于嵌套循环 for (i) for (j),ITG 的边会先连接固定i下的所有连续j迭代,然后再连接到下一个i的起始迭代。

循环携带依赖图

循环携带依赖图 展示了不同迭代之间的依赖关系。图中每个节点同样代表一个迭代,但有向边表示的是依赖关系(真、反、输出依赖),而非执行顺序。

通过分析LDG,我们可以确定哪些迭代是相互依赖的。LDG中的每个连通分量必须被映射到同一个处理器(线程)上执行,以保证依赖顺序。而不同的连通分量则可以映射到不同的处理器上并行执行,这正是并行性的来源。

LDG应用示例

考虑以下循环:

for (i=0; i<n; i++)
    for (j=1; j<n; j++) {
        S1: a[i][j] = a[i][j-1] + b[i][j]; // 写a[i][j],读a[i][j-1]
        S2: b[i][j+1] = c[i][j] + 1;       // 写b[i][j+1]
    }

分析依赖:

  • 循环携带依赖 (真依赖): 仅存在于S1j循环中,S1[i][j] ->T S1[i][j+1]
  • 循环无关依赖 (反依赖): S1[i][j]b[i][j]S2[i][j-1]b[i][j],但这是同一j迭代内的依赖?需要仔细检查下标。这里主要关注循环携带依赖。

构建LDG:

  • 对于每个固定的ij=1,2,...,n-1S1迭代通过真依赖连接成一条链。
  • 不同i值对应的链之间没有依赖边。

因此,LDG 有 n 个连通分量(每个i对应一条链)。我们可以创建 n 个并行任务,每个任务处理一个特定的 i(即内层j循环)。然后,将这些任务负载均衡地映射到 P 个处理器上。

使用OpenMP实现:

#pragma omp parallel for private(j) schedule(static)
for (i=0; i<n; i++) {
    for (j=1; j<n; j++) {
        a[i][j] = a[i][j-1] + b[i][j];
        b[i][j+1] = c[i][j] + 1;
    }
}
  • parallel for: 并行化外层i循环。
  • private(j): 每个线程需要自己私有的j变量用于内层循环。
  • schedule(static): 静态调度,将i迭代块平均分配给各线程。

循环变换技术 🛠️

有时,直接分析循环无法获得并行性,我们需要对循环进行变换。

循环分布

循环分布将单个循环拆分成多个独立的循环,将原本混合在一起的、具有不同依赖模式的语句分离开。

示例:
原始循环:

for (i=0; i<n; i++) {
    a[i] = a[i-1] + b[i]; // S1: 有循环携带依赖
    b[i] = c[i] + d[i];   // S2: 无循环携带依赖
    c[i] = e[i] * f[i];   // S3: 无循环携带依赖
    d[i] = g[i] / h[i];   // S4: 无循环携带依赖
}

依赖分析:S1存在真依赖(a[i]依赖于a[i-1]),其他语句间无循环携带依赖。

变换后:

// 第一部分:可并行
#pragma omp parallel for
for (i=0; i<n; i++) {
    b[i] = c[i] + d[i];
    c[i] = e[i] * f[i];
    d[i] = g[i] / h[i];
}
// 第二部分:必须串行
for (i=0; i<n; i++) {
    a[i] = a[i-1] + b[i];
}

通过循环分布,我们将可并行的部分分离出来,获得了并行性。但注意,这引入了额外的存储开销(可能需要临时数组存储b,c,d的中间结果),且第二部分仍然是串行的。

更优的实现(使用OpenMP ordered子句和私有变量):

#pragma omp parallel for private(temp) schedule(static, 1)
for (i=0; i<n; i++) {
    // 可并行部分
    temp = c[i] + d[i];
    c[i] = e[i] * f[i];
    d[i] = g[i] / h[i];
    // 必须有序执行的部分
    #pragma omp ordered
    a[i] = a[i-1] + temp; // 使用本线程计算的temp
}
  • private(temp): 每个线程有自己的temp变量,避免共享冲突,且存储开销仅为O(P)。
  • schedule(static, 1): 块大小为1的静态调度,有助于ordered子句有序执行。
  • #pragma omp ordered: 确保循环体内部的这个代码块按迭代顺序i=0,1,2,...执行。

循环倾斜

循环倾斜通过线性变换改变迭代空间的坐标轴,从而改变依赖向量的方向,可能将原本无法并行的循环转换为外层串行、内层并行的形式。

核心概念:距离向量和方向向量

  • 设迭代 T1 依赖于迭代 T2
  • 距离向量 = T1的迭代坐标向量 - T2的迭代坐标向量
  • 方向向量 = 距离向量各分量的符号(+, -, 0)。

对于嵌套循环,只有某些方向向量是“合法的”(即与迭代执行顺序兼容)。例如,对于 for (i) for (j) 的顺序,方向向量 (+, +)(+, 0)(+, -)(0, +)(0, 0) 是合法的,而 (-, +) 等是不合法的。

循环倾斜的目标:找到一个幺模线性变换矩阵U(行列式为±1),将原始迭代空间 (i1, i2) 映射到新空间 (k1, k2),使得:

  1. 变换后所有依赖的方向向量仍然合法。
  2. 在新的依赖方向向量中,存在某个外层循环索引(如k1)对应的分量全部为正+)。这意味着所有依赖都指向“更大的k1值”。

如果条件2满足,那么k1循环必须串行执行(因为后面的迭代依赖前面的)。但是,对于每个固定的k1,内层的k2循环的所有迭代之间没有依赖(因为依赖都指向k1维度),因此k2循环可以完全并行化。

示例:
原始循环(类Jacobi更新):

for (i=1; i<n; i++)
    for (j=1; j<n; j++)
        a[i][j] = (a[i-1][j] + a[i][j-1]) / 2;

依赖分析:存在两个真依赖,距离向量为 (1, 0)(0, 1)。在原始(i,j)空间,无论并行化i还是j循环,都会破坏依赖。

应用循环倾斜变换:令 U = [[1, 1], [0, 1]]。则新旧坐标关系为:

  • k1 = i + j
  • k2 = j
  • 逆变换:i = k1 - k2, j = k2

在新的(k1, k2)空间:

  • 依赖 (1, 0) 变为 (1, 0)
  • 依赖 (0, 1) 变为 (1, 1)
  • 两个新依赖向量的第一个分量都是+。因此,k1循环必须串行,k2循环可以并行。

变换后的循环边界需要重新计算。最终并行代码结构如下:

for (k1=2; k1 <= 2*(n-1); k1++) { // 串行循环
    #pragma omp parallel for
    for (k2=max(1, k1-(n-1)); k2 <= min(n-1, k1-1); k2++) { // 并行循环
        i = k1 - k2;
        j = k2;
        a[i][j] = (a[i-1][j] + a[i][j-1]) / 2;
    }
}

算法级分析:改变算法以获得并行性 🧠

有时,无论怎样变换循环,固有的依赖关系都限制了并行性。此时,可能需要从根本上换用不同的算法来解决问题。

示例:计算斐波那契数列
传统迭代算法:

f[0]=1; f[1]=1;
for (i=2; i<n; i++)
    f[i] = f[i-1] + f[i-2];

该循环存在严重的循环携带依赖,难以并行化。

并行算法:利用矩阵幂运算。
观察发现:

[ F(n)   ]   = [1 1] * [F(n-1)]
[ F(n-1) ]     [1 0]   [F(n-2)]

因此:

[ F(n)   ]   = [1 1]^(n-1) * [F(1)]
[ F(n-1) ]     [1 0]         [F(0)]

问题转化为快速计算矩阵 A = [[1,1],[1,0]](n-1) 次幂。这可以通过并行归约树实现:

  1. 每个处理器持有一个A的副本。
  2. 通过 log(n) 步,两两配对进行矩阵乘法,不断得到 A^2, A^4, A^8, ...
  3. 最终组合得到 A^(n-1)

这样,使用 P 个处理器,可以在 O(log n) 时间内计算出第 n 个斐波那契数,而串行算法需要 O(n) 时间。

这个例子说明,通过深入理解问题并改变算法,有可能突破循环依赖的限制,获得显著的并行加速。然而,这种方法是高度问题相关的。


总结 📝

本节课我们一起学习了共享内存编程中循环并行化的核心内容:

  1. 设计方法论:遵循任务分解、依赖分析、任务映射和实现四个步骤。
  2. 数据依赖分析:理解了真依赖、反依赖和输出依赖三种类型,以及它们对语句执行顺序的约束。
  3. 循环依赖分析:区分了循环携带依赖和循环无关依赖,并引入迭代遍历图和循环携带依赖图来分析和可视化依赖关系。
  4. 循环变换技术
    • 循环分布:将混合依赖的循环拆开,分离出可并行部分。
    • 循环倾斜:通过线性变换迭代空间,改变依赖方向,从而暴露出内层循环的并行性。
  5. 算法级变换:认识到当循环并行化受限时,考虑更换更并行的算法可能是根本解决方案,并以斐波那契数列的并行计算为例进行了说明。

掌握这些分析和变换技术,是设计高效共享内存并行程序的关键。

020:负载均衡与调度 📊

在本节课中,我们将要学习并行计算中的两个核心问题:负载均衡与调度。我们将探讨它们的目标、面临的挑战、不同的模型以及一些实用的算法和启发式方法。

概述

负载均衡与调度问题的目标是:给定一组任务和一组并行处理器,我们希望尽可能快地完成所有任务。负载均衡决定将哪些任务分配给哪些处理器,而调度则决定在处理器上执行这些任务的顺序。这两个问题在并行与分布式计算、操作系统、运筹学等领域都有大量研究。

负载均衡与调度的类型

以下是几种不同类型的负载均衡与调度问题。

静态调度

在静态调度中,我们在计算开始前就知道所有任务及其计算量和通信模式。例如,稠密线性代数运算或快速傅里叶变换。由于所有信息已知,我们可以在计算开始时进行一次负载均衡,并且可以花费更多时间来获得更好的平衡。

半静态调度

在半静态调度中,我们周期性地获得关于任务的信息。算法可能分多个阶段运行,在每个阶段开始时,我们知道一些任务信息。例如,粒子模拟中,粒子的位置会缓慢变化,我们可以基于初始位置进行负载均衡,运行一段时间后,当位置变化较大时,再开始新阶段并重新平衡。

动态调度

在动态调度中,我们只在运行时才知道计算内容。例如,在构建搜索树时,节点是动态生成的。因此,我们需要在运行时不断进行重新平衡,这要求算法必须非常高效。

负载均衡的粒度与方式

负载均衡可以在不同粒度上进行,也可以采用集中式或分布式的方式。

细粒度与粗粒度

  • 细粒度负载均衡:将每个任务视为独立个体。这可以获得最佳结果,但任务数量庞大时,会消耗大量时间和内存。
  • 粗粒度负载均衡:将多个任务分组,然后在组级别进行调度。这可以降低开销。

集中式与分布式

  • 集中式负载均衡:一个中心节点收集所有任务信息,并计算最优分配方案。它可以基于全局信息做出最佳决策,但该中心节点可能成为性能瓶颈。
  • 分布式负载均衡:多个节点通过相互通信来共同决定如何平衡负载。这种方式更具可扩展性,能更快响应系统变化,但可能无法达到全局最优解,且可能需要多次迭代才能收敛。

分层方法

我们可以结合集中式和分布式的优点,采用分层方法。首先将任务粗粒度分组,然后由中心节点解决粗粒度负载均衡问题(因为组数少,计算快)。接着,中心节点将初步的负载均衡方案分发给其他节点,各节点再基于此进行分布式的细粒度负载均衡。

负载均衡的关键问题

在进行负载均衡时,我们需要考虑几个关键问题:

  1. 负载估计:我们需要估算每个任务的计算负载。
  2. 通信模式预测:对于非静态计算,我们需要预测任务间的通信模式和通信量。
  3. 负载变化与任务迁移成本:如果负载发生变化,我们需要重新进行负载均衡或迁移任务。迁移任务(代码或数据)会产生成本。

基础模型:列表调度算法

我们从一个最简单的模型开始:我们有一组任务,每个任务只有大小(计算量),任务间没有依赖关系,也没有通信。目标是将这些任务分配到 M 个速度相同的处理器上,最小化完成所有任务的时间(即完工时间)。

即使在这个简单模型中,最小化完工时间也是 NP 难问题(即使只有两个处理器)。我们可以通过将 子集和问题 归约到两处理器完工时间最小化问题来证明这一点。

因此,在实践中,我们寻求近似算法。列表调度 是一种简单的贪心算法:

  • 将任务按任意顺序列出。
  • 每当有处理器空闲时,就将列表中的下一个任务分配给它。

列表调度算法可以保证其产生的完工时间 M 最多是最优完工时间 M* 的两倍,即它是一个 2-近似 算法:M ≤ 2 * M*

列表调度算法简单高效,在实践中常用。即使任务间存在前驱约束(即某些任务必须在其他任务完成后才能开始),只需修改为“将下一个所有前驱任务已完成的任务分配给空闲处理器”,该算法依然能保持 2-近似的性能保证。

列表调度的局限性

列表调度在最坏情况下可能达到 2 倍于最优解的完工时间。考虑一个例子:有 M 个处理器,M² 个大小为 1 的任务,和 1 个大小为 M 的任务。如果列表顺序是先排所有小任务,最后排大任务,列表调度的完工时间约为 2M,而最优调度(先排大任务)的完工时间约为 M+1。

改进:最长处理时间优先调度

列表调度失败的原因在于,大的任务被安排在了最后,破坏了之前的负载平衡。最长处理时间优先 调度通过先安排大任务来解决这个问题。

LPT 调度算法:

  • 将任务按处理时间从大到小排序。
  • 使用列表调度算法(按此排序后的列表)进行分配。

LPT 调度可以证明是一个 4/3-近似 算法:M ≤ (4/3) * M*。其证明思路是分析最后一个完成的任务:如果它“小”,则完工时间有界;如果它“大”,则可以证明 LPT 实际上给出了最优调度。

尽管 LPT 有更好的理论保证,但列表调度在实践中更常用,因为:

  1. 动态性:列表调度可以处理动态到达的任务,而 LPT 需要预先知道所有任务并进行排序。
  2. 无需负载估计:列表调度不关心任务大小,而 LPT 需要知道任务大小来排序。

几何负载均衡

现在我们将关注点转向具有几何坐标的任务,例如粒子模拟,其中通信只发生在附近的任务之间。我们假设所有任务大小相同,目标是平衡负载并最小化处理器间的通信。

递归二分法

递归二分法在几何空间上递归地划分任务点集:

  1. 首先在 x 方向找一条垂直线,将点集分成左右数量相等的两部分。
  2. 然后在每个子区域中,在 y 方向找水平线进行平分。
  3. 如此交替在 x 和 y 方向递归进行,直到分区数量等于处理器数。

这种方法简单,但可能产生高纵横比的分区(即形状又长又窄),导致分区边界变长,通信开销增加。

空间填充曲线法

空间填充曲线(如 Z 曲线、希尔伯特曲线)是一种能填满空间的 1D 曲线。我们将所有任务点映射到这条曲线上,然后将这条 1D 曲线切成若干段,每段包含大致相同数量的点,并分配给一个处理器。由于空间填充曲线具有空间局部性(曲线上接近的点在原始空间中也接近),这种方法能在平衡负载的同时,让相邻的任务尽可能被分配到同一个处理器,从而减少通信。

惯性分割法

惯性分割法旨在找到一条切割线,使得被切割的边(即通信)尽可能少。其思想是:

  1. 寻找一个旋转轴 L,使得所有点绕 L 旋转的转动惯量最小。这对应着点集在垂直于 L 的方向上“最薄”,即跨切割线的通信潜力最小。
  2. 将所有点投影到直线 L 上。
  3. 找到投影值的中位数,并作一条垂直于 L 的切割线,将点集分成两部分。
  4. 如果需要更多分区,则递归应用此方法。

基于图的划分

当任务和通信可以用图来表示时,我们使用图划分。图中节点代表任务(可有权重表示计算量),边代表通信(可有权重表示通信量)。目标是将图划分为 k 个部分(k 为处理器数),使得:

  1. 每个部分的节点权重和大致相等(负载均衡)。
  2. 被切割的边的权重和最小(通信最小化)。

图划分本身也是 NP 难问题。以下是几种启发式方法:

局部搜索:Kernighan-Lin 算法

KL 算法是一种贪心改进算法:

  1. 从任意一个将节点平分为两部分的初始划分开始。
  2. 进行多轮迭代。在每轮中,考虑交换一对分别来自两个分区的、尚未在本轮中使用过的节点,选择能使切割边权重减少最多(或增加最少)的一对进行交换,并标记它们为已使用。
  3. 一轮迭代完成后,检查本轮中产生的最佳划分(切割边权重最小)。如果它优于当前划分,则采用它并重新开始算法;否则,保持当前划分。

谱划分法

谱划分利用图的拉普拉斯矩阵的特征向量。拉普拉斯矩阵 L 定义为:L = D - A,其中 D 是度矩阵(对角矩阵),A 是邻接矩阵。

  1. 计算 L第二小特征值(称为 Fiedler 值)对应的特征向量。
  2. 将特征向量的分量按值排序。
  3. 根据排序后的顺序,将节点分成两部分:一部分对应负特征值分量,另一部分对应正特征值分量。

这种方法通常能产生质量很高的划分,因为特征向量蕴含了图的整体连通性信息。但计算特征向量的开销较大。

多级划分法

多级划分法结合了速度和质量:

  1. 粗化:通过将图中的节点配对(匹配)并合并,生成一个更小的、保留原图结构的粗化图。重复此过程,得到一系列越来越小的图。
  2. 初始划分:在最粗的图上应用划分算法(如谱划分或 KL),因为图很小,所以很快。
  3. 解粗化与细化:将粗化图上的划分投影回上一层较细的图中。然后在较细的图上使用局部搜索算法(如 KL)对划分进行细化,以改进质量。
  4. 重复步骤 3,直到得到原始图上的划分。

动态负载均衡

对于任务动态创建的场景(如搜索算法),我们需要分布式负载均衡。

扩散法

扩散法是一种“推”策略:负载过重的处理器主动将一些任务发送给邻居处理器。任务像扩散一样在网络中传播,直到负载均衡。缺点是可能收敛慢,且通信开销大,因为繁忙的处理器也需要参与任务传递。

工作窃取法

工作窃取法是一种“拉”策略,在实践中更高效:

  • 每个处理器维护一个双端队列存放自己的任务。
  • 处理器从自己队列的头部取出任务执行。
  • 当处理器创建新任务时,将其放入自己队列的尾部
  • 如果一个处理器的队列为空(空闲),它会随机选择另一个处理器,并尝试从该处理器队列的尾部“窃取”任务。

这种方法的优点是:

  • 开销低:只有空闲处理器才需要付出窃取开销,繁忙处理器只需处理自己的任务。
  • 冲突少:由于窃取是从队列尾部,而任务执行是从头部,两者通常不冲突,减少了同步需求。

工作窃取被广泛应用于实践,例如 Cilk 语言就基于此模型。

总结

本节课我们一起学习了并行计算中负载均衡与调度的核心概念。我们从最简单的列表调度模型及其理论保证开始,探讨了针对几何数据的划分方法(递归二分、空间填充曲线、惯性分割)和基于图表示的划分算法(局部搜索、谱划分、多级划分)。最后,我们了解了适用于动态场景的分布式负载均衡策略,特别是高效的工作窃取法。理解这些基本思想和方法,是设计和实现高效并行程序的基础。

021:稠密矩阵算法

在本节课中,我们将学习几种针对稠密矩阵的并行算法。稠密矩阵是指大多数元素非零的矩阵,与稀疏矩阵(大多数元素为零)不同。稠密矩阵运算,尤其是矩阵乘法,通常是计算密集型的,并且由于其规则的结构,非常适合进行高度优化。我们将从矩阵向量乘法开始,然后探讨矩阵矩阵乘法,最后学习如何使用高斯消元法求解线性方程组。

矩阵向量乘法

上一节我们介绍了稠密矩阵的基本概念,本节中我们来看看如何并行地进行矩阵向量乘法。我们假设有一个 M x N 的矩阵 A 和一个 N x 1 的向量 x,目标是计算 y = A * x。

一维算法

首先,我们介绍一种一维划分的并行算法。该算法假设处理器按行划分矩阵 A。

算法概述:
我们假设有 P 个处理器。矩阵 A 被按行划分为 P 块,每个处理器获得 N/P 行。向量 x 初始时也被划分为 P 块,每个处理器拥有 N/P 个元素。

执行步骤:

  1. 数据广播: 每个处理器将其拥有的那部分向量 x 广播给所有其他处理器。这通过一个“全到全广播”操作完成。
  2. 本地计算: 每个处理器在获得完整的向量 x 后,使用其拥有的那部分矩阵 A 的行与向量 x 进行乘法计算,得到部分结果向量 y 的若干行。
  3. 结果收集: 每个处理器计算出的结果就是最终输出向量 y 的一部分,无需进一步合并。

复杂度分析:

  • 计算时间: 每个处理器进行 (N/P) * N = N²/P 次运算。
  • 通信时间: 全到全广播的时间复杂度为 T_comm = (t_s * log P + t_w * N),其中 t_s 是启动时间,t_w 是传输一个数据字的时间,log P 是超立方网络上的跳数。
  • 可扩展性: 为了保持等效率(即计算量增长至少与通信开销一样快),需要 N² ≥ P * N * log P。这推导出 P 最多可达 O(N) 量级。因此,该算法最多可扩展到与矩阵行数相当的处理器数量。

二维算法

为了提高可扩展性,我们引入一种二维划分的算法。该算法将处理器排列成 √P × √P 的网格,每个处理器存储矩阵 A 的一个方块。

算法概述:
矩阵 A 被划分为 √P × √P 个方块,每个方块大小为 (N/√P) × (N/√P)。向量 x 初始时仅存储在处理器网格的最后一列。

执行步骤:

  1. 向量分发: 将向量 x 的各个块从最后一列处理器发送(或广播)到网格对角线上的对应处理器。
  2. 列内广播: 对角线上的每个处理器将其拥有的向量 x 块在其所在的列内进行广播。
  3. 本地计算: 每个处理器用其拥有的 A 矩阵方块与接收到的 x 向量块进行乘法,得到一个部分和(即输出向量 y 的某个元素的部分结果)。
  4. 行内规约: 每个处理器将其计算出的部分和在其所在的行内进行规约(求和)操作。规约结果最终存储在最后一列处理器上,形成完整的输出向量 y。

复杂度分析:

  • 计算时间: 每个处理器进行 (N/√P) * (N/√P) = N²/P 次运算。
  • 通信时间: 主要开销在于步骤1的发送和步骤2、4的广播与规约。每次通信的数据量约为 N/√P,在 √P 个处理器上进行广播/规约的时间约为 t_s * log √P + t_w * (N/√P)
  • 可扩展性: 等效率条件要求 N² ≥ P * (N/√P) * log P。这推导出 P 最多可达 O(N² / log² P) 量级,远优于一维算法。基本原因是通信量从 O(N) 减少到了 O(N/√P)。

矩阵矩阵乘法

在掌握了矩阵向量乘法后,我们进一步探讨更复杂的矩阵矩阵乘法 C = A * B。

简单分块算法

首先介绍一个基础的分块乘法算法。

算法概述:
将 A、B、C 矩阵都划分为 √P × √P 个方块。处理器排列成 √P × √P 的网格,每个处理器初始存储一个 A 方块和一个 B 方块。

执行步骤:

  1. A 的行广播与 B 的列广播: 每个处理器将其拥有的 A 方块在其所在的行内进行全到全广播。同时,每个处理器将其拥有的 B 方块在其所在的列内进行全到全广播。
  2. 本地计算: 广播完成后,每个处理器获得了计算其 C 方块所需的所有 A 行块和 B 列块。然后,它执行本地分块乘法并累加,得到最终的 C 方块。

复杂度与问题:

  • 计算时间: 每个处理器进行 (N/√P)³ * √P = N³/P 次运算。
  • 通信时间: 广播的数据量为 N²/P,在 √P 个处理器间广播的时间约为 t_s * log √P + t_w * (N²/√P)
  • 可扩展性: 可扩展至 P = O(N²) 量级。
  • 内存问题: 每个处理器最终需要存储 √P 个 A 方块和 √P 个 B 方块,总存储量为 O(N²/√P)。所有处理器总存储量为 O(N²√P),是必要存储量(2N²)的 √P 倍,内存效率低。

Cannon 算法

为了解决内存效率问题,我们学习 Cannon 算法。它在保持相同计算效率的同时,显著减少了内存使用。

算法核心思想:
Cannon 算法也使用 √P × √P 的处理器网格和分块。其关键是通过巧妙的初始数据移位和后续的循环移位,确保在每个计算阶段,每个处理器本地的 A 方块和 B 方块正好是正确配对的,从而可以直接相乘并累加到结果 C 方块中。

执行步骤:

  1. 初始移位: 对矩阵 A,第 i 行的所有块向左循环移位 i 个位置。对矩阵 B,第 j 列的所有块向上循环移位 j 个位置。
  2. 计算并移位: 重复以下步骤 √P 次:
    • 本地乘加: 每个处理器将其本地的 A 方块和 B 方块相乘,并将结果累加到其 C 方块中。
    • 循环移位: 所有处理器的 A 方块向左循环移位 1 位,所有处理器的 B 方块向上循环移位 1 位。

优势分析:

  • 内存高效: 在任何时刻,每个处理器只存储一个 A 方块和一个 B 方块,所有处理器的总存储量仅为 O(N²)。
  • 计算与通信: 计算量为 N³/P。每轮移位通信的数据量为 N²/P。总通信开销与简单分块算法同量级,可扩展性相同(至 O(N²)),但内存占用大大减少。

SUMMA 算法

Cannon 算法要求矩阵是方阵且尺寸能被 √P 整除。SUMMA(Scalable Universal Matrix Multiplication)算法提供了更灵活的替代方案。

算法核心思想:
SUMMA 基于矩阵乘法的外积形式。它将计算 C = A * B 转化为一系列秩-1 矩阵(外积)的求和:
C = Σ_{k=1}^{N} (A[:, k] * B[k, :])
其中 A[:, k] 是 A 的第 k 列,B[k, :] 是 B 的第 k 行。

执行步骤:
对于 k 从 1 到 N(或分块后的块数):

  1. 广播列与行: 将 A 的第 k 列块在其所在的行内广播,将 B 的第 k 行块在其所在的列内广播。
  2. 本地外积计算: 每个处理器用接收到的 A 列块和 B 行块计算外积,并累加到其部分结果 C 块上。

优势:

  • 灵活性: 不要求矩阵为方阵,处理器网格也不必是方阵,支持更通用的矩阵形状和处理器映射。
  • 通信模式规整: 通信模式是简单的行广播和列广播。

三维矩阵乘法算法

前述算法最多能有效利用 O(N²) 个处理器。为了利用多达 O(N³) 个处理器以实现亚线性时间,我们介绍三维矩阵乘法算法。

算法概述:
使用 P 个处理器,将它们排列成三维网格,尺寸为 P^{1/3} × P^{1/3} × P^{1/3}。将矩阵 A、B、C 也进行相应分块。

算法思想:
该算法可以看作是 SUMMA 算法在三维空间上的展开。第三维(K 维度)对应了 SUMMA 中不同的外积阶段 k。

  1. 数据重分布: 将 A 的第 k 列块发送到第 k 层(三维网格的 K 维度),并在该层内进行行广播,使得该层所有处理器都拥有该列块的副本。对 B 的第 k 行块进行类似操作(发送到第 k 层,列广播)。
  2. 本地计算: 每个处理器将其本地拥有的 A 块和 B 块相乘。
  3. 规约求和: 对于结果 C 的每个块,需要将三维网格中 K 维度上所有处理器计算出的部分结果进行规约求和,得到最终结果。

复杂度与可扩展性:

  • 当每个处理器只处理单个元素时,运行时间可达 O(log N),但总计算工作量增至 O(N³ log N)。
  • 通过使用分块(块大小为 N / P^{1/3}),可以使每个处理器的计算时间为 O(N³ / P),通信时间为 O((N² / P^{2/3}) * log P)。
  • 等效率分析表明,该算法可扩展至 P = O(N³ / log³ N) 量级,远超二维算法。

线性方程组求解

最后,我们探讨如何并行求解稠密线性方程组 A*x = b,其中 A 是 N x N 矩阵。核心方法是高斯消元法。

高斯消元与 LU 分解

高斯消元法通过一系列行变换将矩阵 A 化为上三角矩阵 U。同时,这些变换可以隐含地生成一个单位下三角矩阵 L,使得 A = L * U。

  • 求解过程: 先解 Ly = b(前向替换),再解 Ux = y(后向替换)。
  • 复杂度: 高斯消元(LU 分解)需要 O(N³) 时间,而每次求解(两次替换)仅需 O(N²) 时间。因此,当需要多次求解不同 b 但相同 A 的方程组时,LU 分解非常高效。

并行高斯消元

简单并行化(非流水线):
将矩阵 A 按行划分给各个处理器。在消元第 k 步时,拥有第 k 行的处理器将其行(经过规范化后)广播给所有其他行号大于 k 的处理器。这些处理器收到后,用其更新自己的行。

  • 问题: 通信开销大,总工作量为 O(N³ log N),不是最优的。

流水线并行化:
为了优化,我们采用流水线技术,使多个消元步骤重叠执行。

  • 核心操作: 每个处理器循环执行“接收-发送-消元”操作。
  • 流程: 拥有主元行的处理器在规范化该行后,并不一次性广播整行,而是将其依次发送给下一个处理器。每个处理器在收到数据后,立即将其转发给下一个处理器,并同时利用收到的数据消去自己行中的对应元素。
  • 优势: 实现了计算与通信的重叠,并将总工作量降低至最优的 O(N³)。

负载均衡:
简单的按连续行划分会导致严重的负载不均衡,先完成工作的处理器会长时间空闲。

  • 优化方法: 采用循环划分,即将行以轮询方式分配给处理器。例如,有 P 个处理器,第 i 行分配给处理器 (i mod P)。
  • 效果: 这大大改善了负载均衡,将空闲时间总量从 O(N³) 减少到 O(P * N²)。

总结

本节课中我们一起学习了稠密矩阵的多种并行算法。

  1. 矩阵向量乘法: 比较了一维和二维算法,二维算法通过减少通信量获得了更好的可扩展性。
  2. 矩阵矩阵乘法:
    • 简单分块算法 易于理解但内存效率低。
    • Cannon 算法 通过循环移位在保持性能的同时实现了内存高效。
    • SUMMA 算法 基于外积形式,更加灵活。
    • 三维算法 通过利用三维处理器网格,理论上能使用 O(N³) 个处理器,实现更高的并行度。
  3. 线性方程组求解: 重点学习了基于高斯消元法的并行LU分解。通过流水线执行循环划分策略,可以有效优化并行性能,减少通信开销并改善负载均衡。

这些算法展示了如何针对稠密矩阵运算的规则结构设计高效并行的方案,其中通信模式优化、内存布局和负载均衡是关键考量因素。

022:迭代矩阵算法

在本节课中,我们将学习一类称为迭代矩阵算法的算法,并探讨如何并行实现它们。

概述

在上一讲中,我们讨论了求解线性方程组 A x = B 的方法,例如高斯消元法。然而,对于非常大的矩阵(例如百万级变量),即使并行运行,O(n^3) 的时间复杂度也过于缓慢。因此,我们需要更快的算法。

今天,我们将首先关注一类具有特殊结构的矩阵——带状矩阵。对于这类矩阵,我们可以使用迭代解法来更快地求解 A x = B。更一般地,对于稀疏矩阵,我们可以使用迭代算法来获得 A x = B 的近似解。这些算法之所以称为“迭代”,是因为它们从一个初始猜测解 x 开始,通过逐步迭代,使 A x 越来越接近 B,最终收敛到精确解或一个足够好的近似解。

基本迭代方法

A 是一个 n x n 矩阵。我们将 A 分解为 A = M - N,其中 MN 也是 n x n 矩阵。第一个要求是 M 的逆矩阵 M^{-1} 易于计算(例如,M 是对角矩阵)。

假设 x* 是方程 A x = B 的解。将 A = M - N 代入,得到:
M x* = N x* + B

现在定义矩阵 C 和向量 d
C = M^{-1} N
d = M^{-1} B

M x* = N x* + B 两边同时乘以 M^{-1},得到:
x* = C x* + d

迭代算法的核心是反复应用这个公式。我们从某个初始向量 x_0 开始:
x_1 = C x_0 + d
x_2 = C x_1 + d
...
x_{k+1} = C x_k + d

我们的目标是,随着迭代次数 k 的增加,x_k 收敛到真实解 x*

收敛性分析

为了分析收敛性,我们考察误差 e_k = x_k - x*。根据迭代公式 x_{k+1} = C x_k + dx* = C x* + d,可以推导出:
e_{k+1} = x_{k+1} - x* = C (x_k - x*) = C e_k

进一步展开,可以得到:
e_k = C^k e_0

其中 e_0 是初始误差。为了使迭代收敛到 x*,我们需要 e_k 随着 k 增大而趋近于零,即 C^k 趋近于零矩阵。

ρ(C) 为矩阵 C 的谱半径(即其最大特征值的模)。定理:如果 ρ(C) < 1,则对于任意初始向量 x_0,迭代序列 {x_k} 都会收敛到解 x*

雅可比迭代法

基本迭代方法的具体实现取决于如何选择 MN。第一种方法是雅可比迭代法

我们将矩阵 A 分解为三部分:

  • DA 的对角线元素构成的对角矩阵。
  • -LA 的严格下三角部分(不含对角线)。
  • -RA 的严格上三角部分(不含对角线)。

因此,A = D - L - R

在雅可比方法中,我们选择:
M = D
N = L + R

由于 M 是对角矩阵,其逆矩阵易于计算。此时:
C = M^{-1} N = D^{-1} (L + R)
d = D^{-1} B

C 矩阵的元素可以显式写出。对于非对角线元素 (i, j)i ≠ j):
C_{ij} = -A_{ij} / A_{ii}
对角线元素 C_{ii} = 0

向量 d 的第 i 个分量为:
d_i = B_i / A_{ii}

迭代公式 x_{k+1} = C x_k + d 按分量写为:
x_{i}^{(k+1)} = (B_i - Σ_{j≠i} A_{ij} x_j^{(k)}) / A_{ii}

收敛条件:如果矩阵 A严格对角占优的,即对于每一行 i,有 |A_{ii}| > Σ_{j≠i} |A_{ij}|,则可以保证雅可比迭代收敛。

雅可比方法的并行实现

观察迭代公式 x_{i}^{(k+1)} = (B_i - Σ_{j≠i} A_{ij} x_j^{(k)}) / A_{ii}。为了计算第 k+1 次迭代的第 i 个分量,我们只需要第 k 次迭代的所有分量 x_j^{(k)}。并且,不同 i 对应的 x_{i}^{(k+1)} 的计算之间没有依赖关系。

因此,一旦我们有了 x^{(k)},所有 x^{(k+1)} 的分量都可以并行计算

以下是使用MPI实现并行雅可比方法的伪代码思路:

  1. 数据分布:将矩阵 A 按行分块,向量 Bx 也相应分块。每个处理器拥有 n/p 行数据(n 为矩阵维数,p 为处理器数)。
  2. 局部计算:每个处理器使用自己存储的 A 的行块、B 的子向量以及全局的 x^{(k)}(来自上一次迭代),计算自己负责的那部分 x^{(k+1)} 的分量。
  3. 全局通信:使用 MPI_Allgather 操作,每个处理器将自己计算出的 x^{(k+1)} 局部块发送给所有其他处理器,从而让每个处理器都获得完整的 x^{(k+1)} 向量,用于下一次迭代。
  4. 收敛判断:计算两次迭代间 x 的变化量,如果小于预设容差或达到最大迭代次数,则停止迭代。

高斯-赛德尔迭代法

高斯-赛德尔迭代法是雅可比方法的一个改进,通常收敛更快。

我们仍然有 A = D - L - R。但在高斯-赛德尔方法中,我们选择:
M = D - L
N = R

这里 M 是一个下三角矩阵(包含对角线),其逆矩阵可以通过前向替换法高效计算。此时:
C = M^{-1} N = (D - L)^{-1} R
d = (D - L)^{-1} B

迭代公式 x_{k+1} = C x_k + d 可以推导出按分量的形式:
x_{i}^{(k+1)} = (B_i - Σ_{j=1}^{i-1} A_{ij} x_j^{(k+1)} - Σ_{j=i+1}^{n} A_{ij} x_j^{(k)}) / A_{ii}

关键区别:在计算 x_{i}^{(k+1)} 时,对于下标 j < i 的分量,我们使用当前迭代 k+1 中已经计算出的新值 x_j^{(k+1)};而对于 j > i 的分量,我们仍然使用上一次迭代 k 的旧值 x_j^{(k)}

收敛性:如果矩阵 A 是严格对角占优的,高斯-赛德尔方法也能保证收敛。直观上,由于它部分使用了更“新鲜”(当前迭代)的数据,因此通常比雅可比方法收敛得更快。

并行性的挑战

然而,高斯-赛德尔方法的并行化面临挑战。观察公式 x_{i}^{(k+1)} 依赖于 x_{1}^{(k+1)}, x_{2}^{(k+1)}, ..., x_{i-1}^{(k+1)}。这意味着在计算第 k+1 次迭代时,分量必须按顺序计算:x_{1}^{(k+1)},然后 x_{2}^{(k+1)},依此类推。

因此,我们无法像雅可比方法那样同时计算 x^{(k+1)} 的所有分量。并行性仅限于对每个固定 i,在计算 x_{i}^{(k+1)} 时,对其求和项 Σ_{j≠i} A_{ij} ... 进行并行化。如果矩阵维数 n 不是非常大,这种细粒度的并行可能无法有效利用大量处理器,通信开销可能占主导。

逐次超松弛迭代法

SOR方法是高斯-赛德尔方法的进一步推广,通过引入松弛因子 ω 来加速收敛。

迭代公式为:
x_{i}^{(k+1)} = (1 - ω) x_{i}^{(k)} + (ω / A_{ii}) [ B_i - Σ_{j=1}^{i-1} A_{ij} x_j^{(k+1)} - Σ_{j=i+1}^{n} A_{ij} x_j^{(k)} ]

ω = 1 时,SOR方法退化为高斯-赛德尔方法。当 0 < ω < 2 且矩阵 A 对称正定时,SOR方法收敛。

SOR方法的并行性与高斯-赛德尔方法相同,面临同样的顺序依赖挑战。

特殊应用:求解泊松方程

对于具有特殊结构的矩阵,我们可以设计出并行性更好的高斯-赛德尔迭代。一个经典的例子是求解泊松方程的离散化系统。

泊松方程 ∇²φ = f 在二维正方形区域上离散化后,可以得到一个线性方程组。每个网格点 (i, j) 上的未知数 u_{ij}(代表 φ 在该点的值)满足:
4u_{ij} - u_{i-1,j} - u_{i+1,j} - u_{i,j-1} - u_{i,j+1} = b_{ij} (忽略边界和常数因子)

其中 b_{ij}f 相关。这形成了一个 n x n 的网格(N = n^2 个未知数)。对应的系数矩阵 A 每行只有5个非零元素:对角线为4,上下左右四个邻居位置为-1。

对角线迭代法

如果我们按行优先的顺序给网格点编号,那么高斯-赛德尔迭代中,计算某个点 x_{k+1} 需要其左边和上边邻居的 x_{k+1} 值。这形成了网格上的计算依赖关系。

观察依赖图可以发现,所有位于同一条“反斜对角线”(从左上到右下)上的点,它们彼此之间没有依赖关系,因为它们依赖的点都位于前一条对角线上。因此,我们可以按对角线顺序进行迭代

  1. 首先计算第一条对角线上的点(并行)。
  2. 然后计算第二条对角线上的点(并行,但需要第一条对角线的结果)。
  3. 依此类推。

这样,我们可以在每条对角线上实现并行,最大并行度约为 n(网格边长)。虽然比纯顺序计算好,但在迭代开始和结束时,对角线较短,并行度较低。

红黑排序法

为了获得更高的并行度,我们可以采用红黑排序(Checkerboard Ordering)。将网格点像国际象棋棋盘一样染成红色和黑色。在排序时,将所有红点排在前面,所有黑点排在后面。

在这种排序下,系数矩阵 A 具有特殊的分块结构。重要的是,任何一个红点只与黑点相连,任何一个黑点只与红点相连

应用高斯-赛德尔迭代到这种排序的方程上,可以推导出:

  • 所有红点的 x_{k+1}只依赖于黑点的 x_{k} 值。
  • 所有黑点的 x_{k+1}只依赖于红点的 x_{k+1} 值。

这产生了巨大的并行优势:

  1. 阶段一:可以并行计算所有红点的新值 x_{k+1},因为它们只依赖于上一轮的黑点旧值,彼此间无依赖。
  2. 同步通信,交换红点新值。
  3. 阶段二:可以并行计算所有黑点的新值 x_{k+1},因为它们只依赖于刚计算出的红点新值,彼此间也无依赖。

这样,我们每轮迭代可以并行计算大约一半的网格点(所有红点或所有黑点),并行度高达 O(n^2/2),远高于对角线方法的 O(n)

总结

本节课我们一起学习了迭代矩阵算法。

  • 我们从基本迭代公式 x_{k+1} = C x_k + d 出发,理解了其收敛条件 ρ(C) < 1
  • 我们详细探讨了三种经典迭代法:雅可比法高斯-赛德尔法SOR法,分析了它们的公式、收敛性和并行潜力。
  • 雅可比法天然具有很好的并行性,但收敛速度可能较慢。
  • 高斯-赛德尔法和SOR法收敛更快,但固有的顺序依赖限制了其并行扩展性,通常只能并行化内部的求和操作。
  • 最后,我们以求解泊松方程为例,展示了对于具有规则结构(如五对角矩阵)的问题,通过巧妙的对角线迭代红黑排序策略,可以显著提高高斯-赛德尔迭代的并行效率。这说明了针对问题结构设计算法的重要性。

023:排序算法 🧮

在本节课中,我们将学习四种并行排序算法:基数排序、并行归并排序、双调排序和采样排序。我们将探讨它们的基本原理、并行化策略以及各自的优缺点。


基数排序:并行实现

上一节我们介绍了本课要学习的四种算法。本节中,我们来看看第一种:基数排序。基数排序本质上是一种顺序排序算法,但事实证明,它也可以在并行环境中高效运行。

基数排序按数字的每一位(从最低有效位到最高有效位)进行排序。例如,要对一组数字进行递增排序,首先按个位数排序,然后按十位数排序,最后按百位数排序。

一个重要的点是,每次按位排序都必须是稳定的。稳定排序意味着,如果当前排序的位值相同,则需要保持这些数字在上一次排序后的相对顺序。例如,在按百位数排序时,362和397的百位数都是3,形成“平局”。为了保持稳定,需要参考前一次(十位)排序的结果,将362保持在397之前。

基数排序之所以能高效并行化,是因为对每一位的排序操作可以并行执行。这使其在实践中,尤其是在共享内存环境中,成为最快的排序算法之一。

我们将利用并行前缀和操作来实现并行基数排序。前缀和有许多应用,这是其中之一。

我们专注于对一组二进制数字的某一位进行稳定排序。假设我们只关注最后一位(最低有效位),其值只能是0或1。我们将最后一位为0的值称为“零值”,为1的称为“一值”。排序这一位,本质上就是将所有的零值放在所有的一值之前,并保持稳定。

以下是实现步骤:

  1. 提取并翻转位:并行提取所有数字的最后一位,然后将0翻转为1,1翻转为0。我们称这个翻转后的位数组为 E
  2. 计算独占前缀和:对数组 E 计算独占前缀和,得到数组 FF[i] 的值恰好等于在位置 i 之前出现的零值的数量。
  3. 确定最终位置
    • 对于一个在位置 i零值,其排序后的位置就是 F[i](即它前面零值的数量)。
    • 对于一个在位置 i一值,其排序后的位置是:(零值总数) + (i - F[i])。其中 (i - F[i]) 是位置 i 之前一值的数量。
    • 零值总数可以通过 F 的最后一个值加上最后一个元素本身是否是零值来计算。
  4. 数据重排:根据计算出的位置数组 D,将每个输入值并行地“散射”到输出数组的对应位置。

所有步骤(位操作、前缀和、位置计算、数据移动)都可以高度并行化,其中关键的并行操作是前缀和。这使得基数排序在GPU等共享内存架构上表现优异。


并行归并排序

上一节我们介绍了基于前缀和的并行基数排序。本节中,我们来看看经典的归并排序如何并行化。

顺序归并排序是分治算法:将数组不断二分,直到子数组足够小,然后开始将有序子数组合并成更大的有序数组。对于n个元素,有 log n 次分割和 log n 次合并,总时间复杂度为 O(n log n)。

在并行设置中,分割阶段很容易并行(只需划分数据)。挑战在于如何并行地合并两个已排序的数组。顺序合并使用两个指针从左向右移动,这是一个串行过程。

并行归并排序的核心是展示如何在有P个处理器时,用 O(log n) 时间完成一次合并操作。

我们引入的概念。值 x 在集合 S 中的秩 rank(x, S),是 S 中小于或等于 x 的元素个数。

关键性质:对于来自数组 AB 的值 x,其在合并后数组 C = A ∪ B 中的秩,等于其在 A 中的秩加上其在 B 中的秩:
rank(x, C) = rank(x, A) + rank(x, B)

如果数组已排序,那么计算 rank(x, A) 可以在 O(log n) 时间内通过二分查找完成。

基于此,并行合并两个各有n个元素的有序数组 AB 的算法如下(假设有2n个处理器):

  1. A 中的每个元素 A[i] 分配一个处理器。该处理器计算:
    • rank(A[i], A) = i (因为A已排序)。
    • rank(A[i], B):通过对B进行二分查找,耗时 O(log n)。
    • A[i]C 中的最终位置为:pos_A[i] = i + rank(A[i], B)
  2. 同样,为 B 中的每个元素 B[j] 分配一个处理器。该处理器计算:
    • rank(B[j], B) = j
    • rank(B[j], A):通过对A进行二分查找,耗时 O(log n)。
    • B[j]C 中的最终位置为:pos_B[j] = j + rank(B[j], A)

所有处理器的二分查找操作可以并行执行,因此整个合并过程的时间复杂度为 O(log n)。

在完整的归并排序中,有 log n 个合并阶段,每个阶段耗时 O(log n),因此总的并行时间为 O(log² n)。需要注意的是,这个简单版本的总工作量是 O(n log² n),高于顺序算法的 O(n log n)。存在更复杂的流水线化版本可以将工作量降至 O(n log n),但本课不做讨论。


双调排序

上一节我们探讨了并行归并排序。本节中,我们来看一种基于比较器网络的排序算法:双调排序。

首先定义双调序列:一个序列先单调递增然后单调递减,或者可以通过循环移位变成这种先增后减的形式。

双调排序依赖于一个关键操作:双调分割
假设有一个长度为n的双调序列S。将其分成两半:S1 = [a0...a(n/2-1)], S2 = [a(n/2)...a(n-1)]。然后进行如下操作:

  • S1S2 中对应位置的元素配对 (a[i], a[i+n/2])
  • 对于每一对,取出较小值,形成新序列 MinSeq
  • 对于每一对,取出较大值,形成新序列 MaxSeq

性质

  1. MinSeqMaxSeq 都是双调序列。
  2. MinSeq 中的所有元素都小于或等于 MaxSeq 中的所有元素。

基于双调分割,可以构建双调合并操作:将一个双调序列转换成一个完全有序的序列。

  • 对输入的双调序列进行一次双调分割,得到 MinSeqMaxSeq。此时,所有较小元素已位于左侧,较大元素位于右侧。
  • 递归地对 MinSeqMaxSeq 分别进行双调合并。
  • 当序列长度为1时,自然有序。

双调合并可以用比较器网络来实现。一个比较器是一个两输入、两输出的单元,输出可以是(小,大)或(大,小)。双调合并网络由 log n 级构成,每一级包含多个并行工作的比较器,对数据进行一次“分割”。因此,双调合并的并行时间为 O(log n)。

最后,双调排序算法如下:

  1. 将任意输入序列分成两半。
  2. 递归地对前半部分进行升序双调排序。
  3. 递归地对后半部分进行降序双调排序。(此时,整个序列构成了一个双调序列:前半升序,后半降序)。
  4. 对整个序列进行双调合并(升序)。

整个排序过程有 log n 个双调合并阶段,每个阶段耗时 O(log n),因此总并行时间为 O(log² n)。双调排序网络的总工作量是 O(n log² n),并非工作最优,但其常数小,在实际硬件(如FPGA)上往往非常高效。存在像AKS网络这样的工作最优(O(n log n))排序网络,但常数极大,不实用。


采样排序

上一节我们介绍了基于网络的双调排序。本节中,我们来看最后一种算法:采样排序。它特别适用于分布式内存环境。

采样排序是一种基于桶的并行排序算法,目标是让各个处理器负载均衡。

  1. 选择枢轴:从所有待排序元素中随机选取 λ * P 个样本(P为处理器数,λ为一个大于1的因子,如 12 * log n)。
  2. 确定桶范围:对这 λ * P 个样本进行排序,然后从排序后的样本中均匀地选取 P-1 个枢轴(例如,每隔 λ 个取一个)。这些枢轴将整个值域划分为 P 个桶。
  3. 数据划分:每个处理器根据这 P-1 个全局枢轴,将自己持有的本地数据划分到这 P 个桶中。
  4. 桶收集:进行全局通信,使第 i 个处理器收集到所有属于第 i 个桶的数据。
  5. 局部排序:每个处理器对自己拥有的桶内的数据进行局部排序(例如,使用快速排序)。
  6. 结果串联:按处理器编号顺序输出排序后的桶,即得到全局有序序列。

算法的关键在于枢轴的选择。通过采样并选取均匀分布的样本作为枢轴,可以以高概率保证每个桶的大小大致均衡。理论分析(使用切尔诺夫界)表明,当 λ = O(log n) 时,可以保证最大桶的大小不超过 4 * (n / P) 的概率非常高(例如,大于 1 - 1/n²)。这样,每个处理器的负载大致相同,排序时间约为 O((n/P) log (n/P))

采样排序的通信开销相对较低:主要是收集样本、广播枢轴、以及全局桶的重新分布。它在分布式内存并行系统上是一种常用且高效的排序算法。


本节课中我们一起学习了四种并行排序算法:利用前缀和实现高效位排序的基数排序;通过并行合并实现分治的并行归并排序;基于比较器网络和双调序列性质的双调排序;以及适用于分布式环境、通过采样保证负载均衡的采样排序。每种算法都有其适用的架构和场景,是并行计算中重要的基础工具。

024:快速傅里叶变换 🚀

在本节课中,我们将要学习快速傅里叶变换的并行算法。傅里叶变换是数学中最重要的工具之一,它能将信号从空间域转换到时间域。我们将讨论快速傅里叶变换,这是一种高效实现傅里叶变换的方法。由于其高效性,FFT在信号处理、多项式与矩阵运算(如计算卷积和滤波)、数学、机器学习等众多领域有广泛应用。事实上,FFT被评为20世纪十大最重要算法之一。可以说,如果没有FFT算法,我们今天习以为常的许多计算机科学应用都将无法实现。

数学基础:单位根

为了描述傅里叶变换和快速傅里叶变换,我们需要从一些数学概念开始。首先,我们定义所谓的“单位根”,即n次单位根。我们要解方程 ω^n = 1。当然,其中一个解是1本身,但如果我们允许复数,则存在其他解。这些解位于复平面的单位圆上。我们将单位圆等分为n份,每一份对应一个满足方程的复数。

所有解都可以表示为 e^(2πi * k / n) 的形式,其中k从0到n-1。我们定义 ω_n = e^(2πi / n),那么所有n次单位根就是 ω_n 的幂:ω_n^0, ω_n^1, ..., ω_n^(n-1)。

这些数具有一些重要性质:

  1. ω_n^n = 1。
  2. 对于任意k(0 ≤ k ≤ n-1),有 ω_n^(k + n/2) = -ω_n^k。这意味着在单位圆上移动半圈会得到相反数。
  3. (ω_nk)2 = ω_n^(2k) = ω_(n/2)^k。这意味着平方一个n次单位根会得到一个n/2次单位根。

这些性质在FFT中至关重要。

问题定义:离散傅里叶变换

接下来,我们定义要解决的问题:离散傅里叶变换。假设给定一个n-1次多项式:
A(x) = a_0 + a_1*x + a_2*x^2 + ... + a_(n-1)*x^(n-1)
DFT的目标是计算这个多项式在所有n个单位根处的值,即计算 A(ω_n^0), A(ω_n^1), ..., A(ω_n^(n-1))

在顺序计算中,最基础的算法是霍纳法则,计算一个点需要O(n)时间,计算所有n个点则需要O(n^2)时间。当n非常大(如百万级)时,这个算法会非常慢。因此,我们需要更快的算法。

快速傅里叶变换算法

FFT是一种分治算法,能在O(n log n)时间内完成计算。我们假设n是2的幂。

FFT的第一步是将多项式A(x)拆分为两个较小的多项式。我们定义:

  • A_even(x^2):由A(x)的偶数项系数构成的多项式。
  • A_odd(x^2):由A(x)的奇数项系数构成的多项式。

这样,原多项式可以表示为:
A(x) = A_even(x^2) + x * A_odd(x^2)

我们的目标是计算A(x)在n个单位根处的值。利用上述等式,我们可以转而计算A_evenA_oddx^2处的值,然后再进行组合。

这里的关键在于利用单位根的性质。当我们计算A_evenA_odd(ω_n^k)^2处的值时,我们发现:

  • (ω_n^k)^2 = (ω_n^(k + n/2))^2。这意味着原本n个不同的平方值,实际上只有n/2个是互异的。
  • 并且,这n/2个互异的值恰好就是所有的n/2次单位根。

因此,计算一个n点DFT的问题,被规约为计算两个n/2点DFT的问题。我们可以递归地解决这两个子问题。

在递归返回后,我们需要合并结果。合并公式如下:
对于 k = 0 到 n/2 - 1:

  • A(ω_n^k) = A_even(ω_(n/2)^k) + ω_n^k * A_odd(ω_(n/2)^k)
  • A(ω_n^(k + n/2)) = A_even(ω_(n/2)^k) - ω_n^k * A_odd(ω_(n/2)^k)

这个算法的时间复杂度满足递归式 T(n) = 2T(n/2) + O(n),其解为 O(n log n)。

递归展开与位反转序

如果我们展开递归过程,观察最底层的输入顺序,会发现一个有趣的现象。例如,对于n=8,最底层的输入顺序是 a0, a4, a2, a6, a1, a5, a3, a7。这个顺序是原始索引的“位反转”顺序。也就是说,将索引的二进制表示反转后,就得到了这个顺序。

并行实现:电路模型

上一节我们介绍了顺序的递归FFT算法。本节中,我们来看看如何并行实现FFT。首先是一种基于电路模型的实现。

电路模型的基本单元是“蝶形运算”电路。它接收两个输入y0和y1,以及一个旋转因子ω,然后输出 y0 + ωy1 和 y0 - ωy1。

我们可以用这些蝶形电路搭建一个计算FFT的网络。这个网络有log n级,每一级包含n/2个独立的蝶形运算,因此可以并行执行。整个电路可以在O(log n)时间内完成n点FFT的计算。

并行实现:二进制交换算法

现在,我们看看如何在真实的并行计算机架构上实现FFT,首先是二进制交换算法。

该算法同样进行log n个阶段的计算。在每个阶段,处理器需要与另一个处理器交换数据并进行蝶形运算。配对的规则是:在第i个阶段,处理器与ID在第i位上不同的处理器进行通信。

假设有P个处理器,每个处理器负责n/P个数据点。在算法前 log P 个阶段,处理器需要与其它处理器通信。在之后的阶段,所有需要通信的数据都已位于同一处理器内部,因此无需额外通信。

以下是该算法在超立方体网络上的效率分析:

  • 计算时间:每个处理器进行 (n/P) * log n 次运算。
  • 通信时间:在 log P 个阶段中,每个阶段与邻居交换 n/P 个数据。
  • 效率:效率 = 计算时间 / (计算时间 + 通信时间)。为了保持固定效率,问题规模n需要随着处理器数量P增长而增长。其等效率函数为 W = n log n 需要至少与 P log P 成正比(当通信开销较小时),在最坏情况下可能需要与 P^(1 + K) 成正比,其中K与机器通信/计算比有关。

在网格架构上的性能

接下来,我们分析二进制交换算法在网格架构上的性能。假设有一个√P × √P的网格。

在网格上,算法的通信模式会导致严重的网络拥塞。特别是在初始阶段,需要进行长距离通信,这会占用网格中有限的带宽(其二分带宽仅为O(√P))。

分析表明,在网格上实现FFT的等效率函数为 W 需要至少与 P * 2^(√P) 成正比。这意味着为了保持效率,问题规模需要随处理器数量呈指数级增长,因此FFT在网格架构上是不可扩展的。

并行实现:二维转置算法

最后,我们介绍一种更优的并行算法:二维转置算法。该算法旨在改善在通信开销较大的机器上的可扩展性。

算法将n个数据点排列成一个√n × √n的网格。计算过程分为两个阶段:

  1. 列变换:对每一列独立进行√n点FFT。此时所有数据都在处理器内部,无需通信。
  2. 转置:将数据矩阵转置。
  3. 行变换:对转置后的每一列(即原始的行)再次进行√n点FFT。同样无需跨处理器通信。

整个算法唯一的通信开销发生在矩阵转置步骤。这是一个全体到全体个性化通信操作。

以下是该算法的效率分析:

  • 计算时间:每个处理器进行 (n log n) / P 次运算。
  • 通信时间:一次矩阵转置,每个处理器发送和接收约 n/P 个数据。
  • 等效率函数:W = n log n 需要至少与 P^2 log P 成正比。

二维转置算法的等效率函数与机器参数Tw/Tc无关。当机器通信较慢(Tw/Tc较大)时,它通常比二进制交换算法更具可扩展性。

总结

本节课中我们一起学习了快速傅里叶变换的并行算法。我们从DFT的问题定义和单位根的数学性质出发,推导出了分治的FFT算法。然后,我们探讨了三种并行实现方式:基于电路模型的O(log n)时间算法、适用于超立方体的二进制交换算法(其可扩展性受机器通信性能影响),以及通信模式更优的二维转置算法(其等效率函数固定为P^2 log P)。理解这些算法及其在不同互连网络上的性能特征,对于在并行系统上高效处理信号和数值计算至关重要。

025:并行搜索 🧭

在本节课中,我们将要学习并行搜索算法,并了解如何利用这些算法解决离散优化问题。

离散优化问题概述

离散优化问题是指在一个离散集合(例如整数集)上进行优化的问题。我们定义一个子集 S,它可以是有限或无限的,代表问题的可行解集合,这些解必须满足特定的约束条件。我们还有一个定义在集合 S 上的成本函数 F。我们的目标是找到 S 中的一个元素 x,使得 **F(x)** 的值最优(最小或最大)。本节课我们将重点讨论最小化问题。

许多离散优化问题(如规划、调度、布局、物流、控制和各类可满足性问题)都是 NP 难问题。这意味着我们无法找到最优算法,因此需要依赖启发式方法。这些启发式方法会在庞大的解空间中进行搜索,而并行算法可以显著加速这一过程。

搜索问题的图模型

在进行离散优化问题的搜索时,我们通常可以用图来建模。图中的节点代表问题的可能解(包括不可行解),而边则连接那些“相近”的解。我们以某种结构化方式遍历这个图,从而探索所有可能的解,并希望以能快速找到最优解的顺序进行遍历。节点或边可以带有权重,代表解的成本。在搜索过程中,如果到达一个无法继续前进的状态,我们称之为终止状态,否则为非终止状态。

示例:八数码问题

八数码问题是一个经典的离散优化问题。我们有一个 3x3 的方格,其中 8 个格子有数字,1 个是空白。目标是通过移动空白格,将数字排列成正确的顺序(1 到 8)。

对于搜索算法,我们将每个可能的棋盘配置定义为一个状态(节点)。如果两个状态之间可以通过一次移动空白格相互转换,则用边连接它们。这样,我们就构建了一个搜索图,目标是找到从初始状态到目标状态(数字顺序正确)的路径。

示例:混合整数规划

另一种离散优化问题是混合整数规划。其形式化描述如下:

  • 目标:最小化线性目标函数 c^T x
  • 约束:满足线性约束 A x ≥ b
  • 变量要求:部分或全部变量 x 必须为整数。

如果所有变量可以是任意实数,这就是一个可以在多项式时间内高效求解的线性规划问题。然而,一旦要求部分变量为整数,问题就变成了 NP 完全问题。混合整数规划是一个通用框架,可以建模许多其他 NP 完全问题。

为了简化模型,我们假设所有变量都是二进制整数(0 或 1),问题仍然是 NP 完全的。搜索状态定义为对变量的赋值(部分变量可能尚未赋值,称为“自由变量”)。我们从所有变量均为自由变量的初始状态开始,然后按某种顺序依次将自由变量设为 0 或 1,从而生成新的状态,并通过边连接仅在一个变量赋值上不同的状态。这样形成的搜索空间大小为 2^n,非常庞大。

我们可以使用剪枝启发式方法来减少搜索空间。一个简单的启发式方法是:检查当前已赋值变量,并假设所有自由变量都取其可能对约束最有利的值(正系数取 1,负系数取 0),计算每个约束的“最乐观”值。如果即使在这种最乐观的假设下,某个约束仍然无法满足,那么当前节点及其所有后代节点都不可能产生可行解,因此可以将其“剪枝”掉,无需继续探索。

搜索图的结构

搜索图有时是树形结构(从根节点到任意节点有唯一路径),有时则是普通图(存在多条路径到达同一节点)。处理搜索树通常比处理搜索图更容易。我们可以通过复制节点,将一个带有环或多路径的搜索图“展开”成一棵搜索树,但需要注意这可能导致搜索树规模急剧膨胀。

在搜索图中,另一个难点是可能通过不同路径多次发现同一个节点。为了避免重复探索,可以使用重复检测算法,例如将已发现的节点存储在哈希表中。由于存在多条路径,到达一个节点的成本是所有路径中的最小值。在搜索过程中,哈希表可以存储节点及其当前已知的最小到达成本。

顺序搜索启发式算法

在讨论并行化之前,我们先介绍几种顺序搜索启发式算法。

分支定界法

分支定界法是一种结合了剪枝的搜索方法。它按某种顺序遍历搜索图,但在某些时候可以消除部分节点,避免探索。

其核心思想是:在搜索前沿的每个节点 P 上,维护两个值 m(P)M(P),分别代表从节点 P 出发的所有后代节点成本的下界和上界。这些值可以是估计值。

分支定界规则:如果存在两个节点 PQ,满足 m(P) > M(Q),这意味着即使 P 的后代中最好的解,其成本也高于 Q 的后代中最差的解。由于我们寻求最小化解,因此完全没有必要探索 P 的后代,可以将整个 P 的子树剪枝掉。

A* 搜索算法

A* 搜索是一种用于寻找最小成本路径的启发式搜索算法,类似于 Dijkstra 算法,但通过启发函数引导搜索方向,效率更高。

对于每个节点 n,我们定义:

  • g(n):从起始节点到节点 n 的实际成本。
  • h(n):从节点 n 到目标节点的估计成本,这是一个可采纳启发函数,意味着 h(n) ≤n 到目标的实际最小成本。
  • f(n) = g(n) + h(n):通过节点 n 到达目标的总成本的估计下界。

A* 算法维护一个开放列表,存放待探索节点。它总是选择 f(n) 值最小的节点进行扩展。相比之下,Dijkstra 算法只根据 g(n)(实际已发生成本)进行扩展。一个好的 h(n) 越接近真实成本,A* 的效率就越高。如果 h(n) ≡ 0,A* 退化为 Dijkstra 算法;如果 h(n) 恰好等于真实成本,A* 将直接找到最优路径。

八数码问题的启发函数示例

  1. 错位数启发函数:计算不在目标位置的数字个数。这是一个可采纳的下界。
  2. 曼哈顿距离启发函数:计算每个数字当前位置到其目标位置的水平和垂直移动距离之和。这个下界比错位数更紧致(值更大),但仍然是可采纳的。

深度优先搜索及其变种

深度优先搜索是一种沿着分支深入探索到底,再回溯的搜索策略,通常使用栈来实现。

*迭代加深 A :结合了 DFS 和 A 的思想。它进行多轮受限的深度优先搜索。在每一轮中,设置一个成本上限 B,只探索那些 f(n) ≤ B 的节点。如果未找到解,则在下一轮增加 B 的值(例如,设置为上一轮中未探索节点的最小 f 值),进行更深层次的搜索。这种方法结合了 DFS 的空间效率和 A 的启发式引导。

并行搜索算法

并行搜索的主要动机是处理庞大的搜索图。然而,并行化会带来开销,包括进程间通信、负载不均衡以及对共享数据结构的争用。此外,并行搜索的顺序可能与顺序搜索不同,这可能导致效率更高或更低。我们定义搜索开销为并行算法探索的节点数 W_P 与顺序算法探索的节点数 W_S 之比。这个比值可能大于、等于或小于 1。

并行化深度优先搜索

顺序 DFS 使用栈。并行化的一个朴素想法是将栈的不同部分分配给不同进程,但这可能导致严重的负载不均衡。

动态负载均衡与工作窃取:更好的方法是采用动态负载均衡。每个进程维护一个待探索节点的本地队列(双端队列)。当一个进程的队列为空时,它成为“窃取者”,随机选择另一个进程(“受害者”),并从其队列的尾部窃取一部分任务。这种方法有助于平衡负载。

工作窃取策略与开销分析

  • 异步轮询:每个窃取者独立地轮询其他进程作为受害者。需要约 O(P^2) 次窃取尝试才能确保每个进程都被访问过。
  • 全局轮询:所有进程共享一个全局的受害者指针,窃取后原子地递增该指针。需要 O(P) 次窃取尝试,但访问共享变量可能成为瓶颈。
  • 随机窃取:窃取者随机选择受害者。理论上证明,只需 O(P log P) 次窃取尝试就能以高概率覆盖所有进程,且没有共享变量争用,通常效率最高。

为了减少窃取频率(即通信开销),可以设置一个截止深度,只窃取足够“大”(深度超过阈值)的工作块。

并行化其他搜索算法

  • 并行化分支定界/迭代加深:需要维护一个全局的当前最优解。当某个进程找到更好的解时,通过广播更新所有进程。由于最优解不会频繁更新,广播开销通常不大。对于迭代加深的每一轮,广播当前的成本上限 B,然后在该轮内使用并行 DFS 进行搜索。
  • 并行化 A 搜索*:主要挑战在于管理全局的优先队列(按 f(n) 排序)。如果所有进程都竞争队列头部的最小值,将导致严重争用。
    • 一种策略是每次扩展 P 个最小节点(而不仅仅是 1 个),但这可能改变搜索顺序,增加总工作量。
    • 另一种策略是让每个进程拥有自己的优先队列副本,并定期通信以同步发现的具有更小 f 值的新节点。需要在通信开销和搜索效率之间取得平衡。
  • 并行化重复检测:在非树形搜索图中,需要检测重复节点。可以分布式地维护哈希表,但这会引入通信开销。如果每个节点的计算量很大,通信开销相对就不那么显著。

搜索的加速与减速异常

由于并行和顺序搜索的探索顺序不同,可能会出现异常情况:

  • 加速异常:并行搜索探索的总节点数 W_P 少于顺序搜索的 W_S,从而获得超线性加速比。例如,目标节点恰好被一个进程快速找到,而顺序搜索需要探索许多其他节点后才轮到该分支。
  • 减速异常:并行搜索探索的总节点数 W_P 多于顺序搜索的 W_S,导致实际效率下降。例如,目标节点在顺序搜索中很快被找到,但并行搜索中负责该分支的进程启动较晚或负载较重。

并行与顺序搜索效率模型分析

我们可以建立一个简化的概率模型来比较并行和顺序 DFS 的效率:

  • 假设搜索树有 M 个叶子节点,解只存在于叶子节点。
  • 将树划分为 M 个大小相等的分区。
  • i 个分区中,每个叶子节点是解的概率为 ρ_i,且相互独立。
  • 顺序 DFS:随机选择一个分区并完全搜索它,若未找到解则换下一个分区。其期望搜索的叶子数正比于 ρ_i调和平均数的倒数。
  • 并行 DFS:使用 M 个进程,每个进程同时搜索一个不同的分区。在每一步,所有进程并行探索 M 个叶子。其期望停止时间(步骤数)反比于 ρ_i算术平均数。总工作量(进程数 × 步骤数)正比于算术平均数倒数的 M 倍。

根据数学不等式,算术平均数 ≥ 调和平均数。因此,在这个模型下,并行 DFS 的总工作量永远不会超过顺序 DFS 的总工作量。当所有分区的解概率 ρ_i 都相等时,两者工作量相等;当解概率分布不均匀时,并行算法的工作量更少,可能实现超线性加速。这是因为并行算法能够同时“采样”所有分区,而顺序算法可能不幸地从解概率低的分区开始搜索。

总结

本节课我们一起学习了并行搜索的核心内容。我们首先了解了离散优化问题及其图模型,并通过八数码和混合整数规划问题加深了理解。接着,我们探讨了几种重要的顺序搜索启发式算法:使用上下界剪枝的分支定界法、利用可采纳启发函数引导的 A 搜索算法,以及深度优先搜索及其变种*迭代加深 A* **。

然后,我们重点讨论了如何将这些算法并行化。对于 DFS动态负载均衡工作窃取是关键技术,其中随机工作窃取策略在实践中表现优异。对于 A 搜索,管理共享的优先队列是主要挑战,需要权衡通信开销与搜索效率。我们还分析了并行搜索中可能出现的加速与减速异常*,并通过一个概率模型说明,当解在搜索空间中分布不均时,并行搜索可能天然具有效率优势。

并行搜索是解决大规模组合优化问题的有力工具,其设计需要在算法效率、负载均衡和通信开销之间进行精细的权衡。

026:PRAM模型基础与算法

在本节课中,我们将学习一种新的并行计算模型——PRAM模型,并探讨其基本概念和几个基础算法。我们将从模型的定义开始,逐步深入到具体的并行算法设计,包括并行进位加法、最大值查找和链表排名。

PRAM模型介绍

上一节我们回顾了冯·诺依曼模型。本节中,我们来看看它的并行扩展——PRAM模型。

PRAM代表“并行随机存取机器”。这个模型旨在将顺序计算的冯·诺依曼模型推广到并行设置。在冯·诺依曼模型中,我们有一个CPU和一个内存,CPU在每个时间步从内存读取数据,进行计算,然后将结果写回内存。在PRAM模型中,我们拥有多个CPU。在每个时间步,这些CPU可以并行地与内存通信:每个CPU可以读取内存的不同部分,并行进行计算,然后并行地将结果写回内存。这就是PRAM计算的一个周期,CPU会不断重复这些周期。

关于CPU数量,我们可以根据问题规模n来选择使用f(n)个处理器,其中f是我们选择的函数。例如,我们可以使用√n或n log n个处理器。在PRAM模型中,我们甚至可以使用比输入规模更多的处理器,例如,排序一百万个数字时,可以使用一百万个甚至更多的处理器。

这些处理器是同步的。在每个时间步,所有处理器同步地从内存读取,同步地计算,然后同步地写回。这类似于我们之前讨论过的SIMD(单指令多数据)模型,但区别在于,PRAM中的不同CPU可以执行不同的指令,因此它属于MIMD(多指令多数据)模型,但处理器是同步的。

内存冲突与PRAM子模型

在PRAM模型中,多个处理器可能同时读写同一内存位置,这会导致冲突。PRAM模型通过定义几个子模型来处理这个问题。

以下是四种主要的PRAM子模型:

  • EREW(互斥读互斥写):这是限制最严格的模型。它不允许处理器在同一时间步读写同一内存位置。如果发生,程序将崩溃。
  • CREW(并发读互斥写):这个模型限制较少。它允许多个处理器在同一时间步读取同一内存位置,但不允许它们写入同一位置。
  • ERCW(互斥读并发写):这个模型不常见,通常不考虑。它允许并发写入,但不允许并发读取。
  • CRCW(并发读并发写):这是最通用的模型。它允许并发读取和并发写入。然而,当多个处理器写入同一位置时,由于是单一内存单元,只能存储一个值。因此,需要仲裁机制来决定哪个值被写入。

CRCW模型本身还有子模型,用于定义并发写入时的仲裁规则:

  • 任意写入:任意一个写入的值成功。
  • 优先级写入:根据处理器或写入值的优先级(例如,最大值或最小值)决定哪个值成功。

PRAM算法的度量指标

在设计PRAM算法时,我们主要优化两个指标:工作量深度

  • 深度:指算法终止前所需的并行步数。由于PRAM允许使用大量处理器,我们通常希望深度非常小,例如O(log n)O(log^k n)
  • 工作量:定义为使用的处理器数量乘以深度,即所有处理器在算法运行期间所能完成的最大计算量。对于一个顺序程序,处理器数为1,深度等于时间复杂度,因此工作量等于其时间复杂度。

我们早期课程中提到的工作量定律指出,并行算法的工作量至少要与解决同一问题的最佳顺序算法的工作量一样大。如果一个并行算法的工作量与最佳顺序算法相同,我们称其为工作量高效的。

在实践中,我们通常更倾向于工作量高效,即使这意味着深度稍大。因为最终我们希望算法能在真实硬件上运行,而真实硬件的处理器数量有限,此时算法的实际运行时间通常由工作量决定,而非深度。

PRAM模型的优缺点

PRAM模型是一个理论模型,它有一些不现实的假设:

  1. 大量处理器:允许使用与问题规模相当甚至更多的处理器,这在现实中不常见。
  2. 忽略通信成本:假设处理器访问内存没有成本,所有成本都在计算中。然而,在几乎所有真实计算模型中,通信(延迟或带宽)是主要成本。

尽管不现实,PRAM模型有其优势:它非常简单和清晰,专注于计算本身。它允许我们使用任意多的处理器,从而最大化挖掘问题中的并行性。PRAM算法展示了问题本身固有的并行度。设计出具有高度并行性的PRAM算法后,下一步是将其映射到有限数量的真实处理器上,并考虑通信成本。许多GPU算法最初就是PRAM算法的改编版本。因此,研究PRAM模型有助于我们理解并行计算的核心思想。

并行进位前瞻加法

现在,我们来看一个可以在PRAM模型上解决的具体问题:并行进位前瞻加法。我们的目标是并行地相加两个二进制数。

假设每个CPU(或全加器)一次只能处理两个比特的加法,并产生一个进位。顺序相加n位数字需要O(n)时间。并行化的难点在于,低位的进位会影响高位的计算,使得各位无法独立计算。

为了并行计算所有进位,我们引入两个变量:

  • 进位生成位G_i = A_i ∧ B_i。如果A_iB_i都是1,则无论当前进位如何,下一位必然产生进位。
  • 进位传播位P_i = A_i ⊕ B_i。如果P_i为1,则下一位的进位等于当前位的进位。

那么,下一位的进位C_{i+1}可以表示为:
C_{i+1} = G_i ∨ (C_i ∧ P_i)

这个关系可以用矩阵乘法表示。如果我们把(C_i, True)看作一个向量,那么:
(C_{i+1}, True) = (C_i, True) * [[P_i, G_i], [False, True]]

通过递推,第i+1位的进位可以表示为初始进位C_0与前面所有位对应矩阵的乘积:
(C_{i+1}, True) = (C_0, True) * (M_1 * M_2 * ... * M_i)
其中M_j = [[P_j, G_j], [False, True]]

关键点:所有P_iG_i可以并行计算(因为只依赖于A_iB_i)。计算所有进位C_i的问题,就转化为了计算这些2x2矩阵的前缀积问题。我们已经知道,使用n个处理器,可以在O(log n)时间内计算n个元素的前缀积。

因此,我们可以在O(log n)时间内计算出所有进位位。一旦有了所有进位,每个位的最终和S_i = A_i ⊕ B_i ⊕ C_i就可以在常数时间内并行计算。所以,整个加法算法可以在O(log n)时间内完成。这个算法或其变体在实际的CPU设计中有所应用。

寻找最大值(快速但不高效的算法)

接下来,我们探讨在PRAM模型上寻找n个数字中的最大值。首先看一个常数时间工作量不高效的算法。

假设我们有p个数字x_1, ..., x_p。我们使用p^2个处理器,并需要CRCW模型(最小值优先级仲裁)。算法步骤如下:

  1. 为每一对数字(x_i, x_j)分配一个处理器。该处理器计算一个比特b_{ij}
    b_{ij} = 1 如果 x_i ≥ x_j,否则为 0
  2. 对于每个i,我们想计算M_i = b_{i1} ∧ b_{i2} ∧ ... ∧ b_{ip}。如果M_i = 1,意味着x_i大于或等于所有其他数字,即x_i是最大值。
    为了并行计算M_i,我们让所有与i相关的处理器(即计算b_{i1}, ..., b_{ip}的处理器)都尝试向内存位置M_i写入它们的b_{ij}值。在最小值优先级CRCW模型下,如果任何一个b_{ij}为0(最小值),那么M_i最终就会被写入0。只有当所有b_{ij}都为1时,M_i才会保持为1(因为1不是最小值,0才是;实际上需要确保写入1的处理器在所有b_{ij}=1时才写入,这里逻辑上使用“与”操作,通过最小值仲裁实现:任何0都会覆盖1)。

这个算法只需要常数时间(两步),但工作量是O(p2)**,对于p=n的情况,工作量是**O(n2),远大于顺序算法的O(n),因此不是工作量高效的。

寻找最大值(高效的双对数树算法)

现在,我们介绍一个更快的、工作量高效的算法,它可以在O(log log n)时间内找到最大值,工作量仅为O(n)。这个算法需要CRCW模型(最小值优先级)

该算法基于一种双对数树结构。树的叶子节点是输入的n个数字。对于每个内部节点u,其度数(子节点数)设为ceil(√(以u为根的子树中的叶子数))

  • 根节点有n个叶子,所以度数为√n
  • 根节点的每个子节点对应的子树有√n个叶子,所以这些子节点的度数为√(√n) = n^{1/4}
  • 以此类推。

可以证明,这样构造的树的高度是O(log log n)

算法过程如下:
从叶子节点开始,每个内部节点并行地计算其所有子节点的最大值。对于一个有p个子节点的内部节点,我们可以使用上面提到的常数时间算法(CRCW,最小值优先级)来计算这p个子节点的最大值,但这需要O(p^2)的工作量。

工作量分析
设树第i层的节点度数为d_i,该层节点数为n_i。根据树的结构性质,可以证明每一层的总工作量n_i * d_i^2是O(n)。由于树有O(log log n)层,所以总工作量是O(n log log n)

这仍然不是严格工作量高效的(顺序算法是O(n))。为了达到严格的工作量高效,我们使用加速级联技术。

加速级联与高效最大值算法

加速级联结合了一个工作量高效但较慢的算法和一个工作量不高效但极快的算法。

具体到最大值问题:

  1. 第一阶段(分块):将n个输入分成 n / log log n 个块,每个块包含 log log n 个数字。使用 n / log log n 个处理器,每个处理器顺序地找出其分配块内的最大值。这一步的时间是O(log log n),工作量是 (n / log log n) * log log n = O(n)
  2. 第二阶段(双对数树):现在我们有 m' = n / log log n 个值(每个块的最大值)。对这些值应用双对数树算法来找出全局最大值。根据之前的分析,这一步的时间是O(log log m') = O(log log n),工作量是 m' * log log m' = O(n)

两个阶段的总时间是O(log log n),总工作量是O(n)。这样我们就得到了一个工作量高效非常快速的并行最大值算法。

链表排名问题

链表排名是一个基础且有用的问题,是许多其他并行算法的子程序。问题定义如下:
给定一个链表,每个节点知道它的下一个节点。对于每个节点,计算它到链表头节点的距离(即排名)。

顺序算法很简单,从头开始遍历即可,需要O(n)时间。并行算法采用指针跳跃技术。

每个节点维护两个变量:dist(当前到链表末尾的距离估计)和next(指向下一个节点)。初始化时,尾节点的dist=0next=null;其他节点的dist=1next指向其原后继。

算法重复以下步骤,直到所有节点的nextnull
对于每个节点i(并行执行),如果其next不为null,则:

  1. dist[i] = dist[i] + dist[next[i]]
  2. next[i] = next[next[i]] (指针跳跃)

算法正确性:可以证明,经过k轮迭代后,对于距离链表末尾实际距离为m的节点,其dist值等于min(m, 2^k),其next指针指向后方min(m, 2^k)距离处的节点。因此,经过O(log n)轮迭代后,所有节点都能计算出正确的距离。

工作量:使用n个处理器,运行O(log n)轮,总工作量为O(n log n)。这比顺序算法的O(n)大,因此不是工作量高效的。

高效链表排名算法

为了使链表排名工作量高效(O(n)),我们采用“压缩-求解-扩展”的策略:

  1. 压缩:反复从链表中移除一个独立集(即不相邻的节点集合),直到剩余节点数降至 n / log n。每次可以找到并移除一个常数比例的节点。移除节点i时,将其距离值加到其前驱节点的距离值上,从而保持剩余节点距离信息的正确性。这个过程需要O(log n)时间(通过后续将介绍的独立集查找算法)。
  2. 求解:对剩余的大约 n / log n 个节点,使用上述指针跳跃算法进行排名。这一步的工作量是 (n / log n) * log(n / log n) = O(n)
  3. 扩展:按照与压缩相反的顺序,将移除的节点插回链表。当插回节点i时,其正确距离等于它被移除时记录的距离(到原后继的距离)加上原后继节点(现已计算出正确排名)的距离。这一步也可以在O(log n)时间内完成。

总工作量分析:压缩阶段每轮处理的节点数按常数比例减少,总工作量是一个收敛的几何级数,为O(n)。求解阶段工作量为O(n)。扩展阶段类似。因此,总工作量为O(n),且总时间为O(log n)。这样就得到了一个工作量高效的并行链表排名算法。

链表排名算法非常有用,可以用于前缀和、欧拉回路技术、连通分量计算和表达式树求值等许多其他并行算法中。

链表上的前缀和

链表排名算法可以稍作修改,用于计算链表上的前缀和。问题定义:每个节点有一个输入值x_i,需要为每个节点计算从头节点到该节点所有值的和。

算法与链表排名类似,但更新规则不同:
在指针跳跃的每一步,对于每个节点i(并行执行),如果其next不为null,则:
dist[next[i]] = dist[i] + dist[next[i]] (将当前值向前传播)
next[i] = next[next[i]]

其不变性是:经过k轮后,前2^k个节点拥有正确的前缀和,其余节点拥有其前面2^k个值的部分和。经过O(log n)轮后,所有节点计算出正确的前缀和。初始工作量也是O(n log n),同样可以采用类似压缩的方法使其达到工作量高效O(n)。

总结

本节课中我们一起学习了PRAM并行计算模型的基础知识。我们首先介绍了PRAM模型的概念、其四种处理内存冲突的子模型(EREW、CREW、ERCW、CRCW)以及衡量算法性能的工作量和深度指标。接着,我们探讨了PRAM模型的优缺点,它虽然理论化,但能揭示问题的内在并行性。然后,我们深入分析了三个基础并行算法:

  1. 并行进位前瞻加法:通过计算进位生成/传播位和矩阵前缀积,在O(log n)时间内完成加法。
  2. 寻找最大值:我们看到了一个快速的常数时间算法(但工作量不高效),以及一个结合了分块和双对数树、达到O(log log n)时间和O(n)工作量的高效算法。
  3. 链表排名与前缀和:我们学习了基于指针跳跃的算法,并了解了如何通过“压缩-求解-扩展”策略将其优化为工作量高效的O(n)算法。这些算法是许多更复杂并行图算法和树算法的基础构件。

通过本节课,我们掌握了在理想化并行模型下设计基础算法的方法论,为后续学习更复杂的并行算法打下了基础。

027:PRAM 图算法

在本节课中,我们将学习几种基于PRAM模型的图算法。我们将从图的着色问题开始,探讨如何快速为环图进行三着色,并利用着色算法高效地寻找独立集。接着,我们将学习两种求解连通分量的算法:一种基于随机化,另一种是确定性的。最后,我们将了解如何利用图收缩技术来求解最小生成树问题。

环图的三着色算法 🎨

上一节我们介绍了PRAM模型的基本概念,本节中我们来看看如何为环图进行三着色。给定一个图G,K着色意味着为每个节点分配一个0到K-1之间的数字,并确保图中任意一条边IJ的两个端点I和J被分配不同的数字。今天,我们将学习如何为一个环图进行三着色。

这个算法虽然只适用于环图,但它速度极快。更重要的是,尽管环图是一种特殊结构,但通过为其着色,我们可以将此算法作为其他重要算法(如寻找独立集)的子程序。

通常我们试图最小化使用的颜色数量。对于一个环,如果节点数为偶数,则仅需两种颜色即可交替着色。然而,如果节点数为奇数,则至少需要三种颜色。我们将给出一个在颜色数量上最优且运行速度极快的算法。

为环图着色是一种“打破对称性”的过程。初始时,所有节点除了ID不同外,没有其他区别。算法将利用这些ID,最终使每个节点获得0到2之间的三种颜色之一。

对于每个节点V,我们令S(V)表示V的后继节点。算法的工作原理如下:初始时,每个节点的颜色就是其ID。由于所有节点ID不同,因此初始时图中任意一条边的两个端点颜色也不同,这是一个有效的着色。

算法将分多轮进行。在每一轮开始时,我们假设已有一个有效着色(即每条边的端点颜色不同)。本轮的目标是减少所使用的颜色数量。例如,如果本轮开始时使用了100种颜色,那么本轮结束后我们希望只使用大约10种颜色。我们不断减少颜色数量,直到只剩下三种颜色,算法终止。

在每一轮中,对于每个节点V,我们查看其当前颜色C(V)及其后继节点颜色的二进制表示。由于V与其后继节点颜色不同,它们的二进制表示必然在至少某一位上不同。我们定义K为这两个颜色二进制表示中,最低有效位不同的位置索引。

然后,我们为V计算一个新颜色C'(V),其计算公式为:
C'(V) = 2 * K + (C(V)在K位的比特值)

例如,假设当前节点颜色为7(二进制0111),其后继节点颜色为2(二进制0010)。它们从最低位(第0位)开始比较,在第0位就不同(1 vs 0),因此K=0。新颜色为 2*0 + 1 = 1

我们有以下断言:如果C是一个有效着色,那么新着色C'也是有效的。证明思路是:假设存在节点V和其后继U在新着色下颜色相同,即C'(V) = C'(U)。根据定义,这要求K_V = K_UC(V)C(U)在K位的比特值相同。但这与K的定义(V和U的当前颜色在K位不同)矛盾。因此,新着色必然有效。

现在的问题是,我们需要运行多少轮?算法的并行运行时间取决于轮数,因为每一轮所有节点可以并行执行上述操作。

假设在某一轮中,表示任何颜色所需的最大比特数为T。例如,若T=4,则所有颜色在0到15之间。在下一轮,新颜色的最大值最多为2T + 1(因为K在0到T-1之间,乘以2再加一个比特值0或1)。因此,表示下一轮颜色所需的最大比特数变为ceil(log(2T+1)),这大约是log T + 1

可以看到,表示颜色所需的比特数从T迅速减少到大约log T。例如,若当前使用16比特(最多65535种颜色),下一轮仅需约log 16 + 1 = 5比特(最多31种颜色)。颜色数量减少得非常快。

为了更严谨地分析,我们定义迭代对数函数log* (x):即需要对x连续取多少次以2为底的对数,其结果才能小于等于1。log* (x)增长极其缓慢。例如,即使x = 2^65000log* (x)也小于等于6。

算法的断言是:对于一个有n个节点的环,我们可以在O(log* n)轮内为其找到一个三着色。原因在于,初始时颜色就是节点ID,需要log n比特表示。每轮将表示颜色的比特数减少到大约其对数,因此在log* n轮后,表示颜色所需的比特数将变为常数。

但有一个细节需要注意:当颜色数量减少到6种时(需要3比特表示),上述子程序无法进一步减少颜色数量。因为此时K在0到2之间,新颜色最大为2*2+1=5,下一轮仍然最多有6种颜色。

然而,我们知道环图最多只需要三种颜色。因此,在得到6种颜色后,我们需要一个不同的子程序来降至三色。具体做法是:再进行三轮。在第i轮(i=1,2,3),我们将所有颜色为i+2的节点(即颜色3、4、5)重新着色。对于每个这样的节点,查看其两个邻居使用的颜色(来自集合{0,1,2}),然后将其着为{0,1,2}中未被邻居使用的那个最小颜色。由于每个节点只有两个邻居,而可用的颜色有三种,因此总有一种颜色可用。三轮之后,所有节点颜色都在0到2之间。

总结一下,我们首先运行O(log* n)轮将颜色减少至6种,然后再运行3轮将其减少至3种。总工作量为O(n log* n),因为每轮所有n个节点都在并行工作。

然而,这并非一个“工作高效”的算法,因为顺序算法只需O(n)时间。但我们可以修改此算法,使其在O(log n)时间和O(n)工作量内完成三着色。这个O(log n)的时间对于将其作为其他算法的子程序通常是足够的。

利用着色寻找独立集 🧩

上一节我们学习了如何为环(或线图)快速着色,本节中我们来看看如何利用着色来高效地寻找独立集。回忆上一讲的内容,在进行高效工作量的列表排名计算时,我们需要快速找到独立集并不断移除其中的节点。

具体来说,如果我们有一个具有n个节点的线图的K着色(K为常数,例如3),那么我们可以在常数时间内计算出一个大小至少为Ω(n/k)的独立集。如果K=3,则意味着我们可以在常数时间内找到一个大小至少为n/3的独立集。

原因如下:给定一个K着色,每个节点的颜色在1到K之间。为了找到独立集,我们将选取所有颜色为“局部最小值”的节点。即,一个节点的颜色小于其所有邻居的颜色。

例如,假设节点颜色如括号内数字所示。节点4的颜色0小于其邻居的颜色1和2,因此它是一个局部最小值,被加入独立集。类似地,其他局部最小值节点也被加入。这些被选中的节点构成了一个独立集,因为根据定义,一个局部最小值节点不可能与另一个局部最小值节点相邻(否则它们无法同时小于对方)。

我们可以在常数时间内计算出所有局部最小值,因为每个节点只需查看其两个邻居即可判断。

接下来需要证明这个独立集足够大,至少包含约n/(2k-3)个节点。原因在于,考虑任意两个被选中的连续节点U和V。在U和V之间的节点,其颜色序列必须先上升(从U的颜色增加到某个峰值),然后再下降(降到V的颜色)。由于总共只有K种不同颜色,在上升部分最多有K-1个节点,在下降部分最多有K-2个节点。因此,U和V之间最多有(K-1)+(K-2) = 2K-3个节点。这意味着,在任意两个被选中的节点之间,间隔的节点数有限,因此被选中的节点数至少为n/(2K-3)

当K=3时,我们至少能得到大小约为n/3的独立集。结合之前的工作高效着色算法(O(log n)时间,O(n)工作量),我们可以在O(log n)时间和线性工作量内,找到一个大小为Ω(n)的独立集。这正是实现高效工作量列表排名算法所需要的组件。

连通分量算法(随机化版本) 🔀

前面我们利用着色解决了寻找独立集的问题,本节开始我们将探讨图论中的另一个基本问题:寻找连通分量。给定一个无向图,我们希望将图划分为若干个极大的连通节点集合。

在顺序计算中,我们通常使用广度优先搜索或深度优先搜索,时间复杂度为O(m+n)。然而,在PRAM模型中,我们没有既能保持线性工作量又具有对数深度的BFS或DFS高效并行算法。

因此,在PRAM中我们使用一种称为“图收缩”的技术。基本思想是:反复将一组节点合并为一个“超节点”,并保留原图之间的边。这个过程会保持原图的连通分量结构。最终,每个连通分量内的所有节点都会被收缩成一个超节点,从而我们可以通过超节点的标签来标识原节点所属的连通分量。

我们首先看一个随机化算法。该算法通过将图分解为许多“星形”图并进行收缩。

算法步骤如下(每轮并行执行):

  1. 随机标记:每个节点随机抛硬币,决定自己成为“父节点”或“子节点”。
  2. 建立指针:每个子节点选择任意一个与之相邻的父节点,并指向它。如果没有相邻父节点,则子节点自身保持独立。
  3. 收缩星形:每个以父节点为中心的星形(包含父节点及其指向它的所有子节点)被收缩到该父节点。星形内部的边被移除。
  4. 更新标签与激活状态
    • 子节点将其标签更新为父节点的标签,然后变为“非活跃”状态。
    • 父节点保持“活跃”状态。
    • 对于边处理器,如果其两端点标签不同,则保持活跃;如果相同(即边在收缩后的超节点内部),则变为非活跃。
  5. 递归处理:在收缩后的新图(由活跃的超节点和活跃的边构成)上递归执行上述过程。
  6. 标签传播:递归返回时,之前非活跃的边处理器可能需要重新激活,帮助将最终标签从父节点传播到所有曾被子节点指向的节点。

算法分析:关键在于,每一轮中,一个顶点处理器有至少1/4的概率变为非活跃。因为一个节点成为子节点的概率是1/2,并且它至少有一个邻居是父节点的概率至少是1/2(因为每个邻居是父节点的概率是1/2,且通常有多个邻居)。因此,每轮期望有1/4的节点被收缩。

经过O(log n)轮后,期望只剩下一个活跃节点(代表一个连通分量)。每轮工作量为O(m+n),时间为常数。因此,总时间为O(log n),总工作量为O((m+n) log n)。这还不是工作高效的(比顺序算法多一个log因子),但存在其他修改版本可以达到线性工作量。

连通分量算法(确定性版本) ⚙️

如果不喜欢随机化算法,我们可以使用确定性算法。它同样基于图收缩思想,但实现方式不同。

算法同样分轮进行,每轮尝试收缩图。在每一轮中,我们进行两个方向的尝试:

  1. 高指向低:让每个节点指向一个ID比它小的邻居(如果存在)。
  2. 低指向高:让每个节点指向一个ID比它大的邻居(如果存在)。

每个尝试都会形成一个由指针构成的森林(每个连通分量成为一个有根树,根是局部最小或最大ID节点)。然后,我们通过“指针跳转”技术,将每棵树中的所有节点快速指向其根节点,从而将这棵树收缩到根节点,形成超节点。

关键观察是:对于任何一个节点V,在上述两个尝试中,至少有一个尝试会使V指向某个邻居。因为如果V在“高指向低”轮中没有指向任何邻居,说明V的ID小于所有邻居,那么在“低指向高”轮中,V必然会指向某个邻居。因此,在至少一个尝试中,V会被收缩(如果它指向了别人)。

因此,我们可以比较两个尝试中分别能收缩多少节点,然后选择收缩节点数较多的那个方向执行本轮操作。可以证明,被选中的那个方向至少能收缩一半的节点。

因此,经过O(log n)轮,所有节点将被收缩。每轮需要进行指针跳转,这需要O(log n)时间。因此,总时间复杂度为O(log² n)。每轮工作量为O((m+n) log n),故总工作量为O((m+n) log² n)

最小生成树算法 🌲

最后,我们来看最小生成树问题的并行算法。给定带权无向图,寻找连接所有节点且总权重最小的树。

我们利用最小生成树的一个关键性质:对于图的任意顶点子集W,在所有连接W与图其余部分的边中,权重最小的那条边必然包含在任一最小生成树中。特别地,对于单个节点,其关联边中权重最小的那条必在MST中。

基于此性质,我们设计一个与随机化连通分量算法类似的收缩算法:

  1. 随机标记:每个节点随机决定成为父节点或子节点。
  2. 选择最小出边:对于每个子节点U,找到其所有出边中权重最小的那条边(U, V)。如果邻居V是父节点,则U指向V。
  3. 收缩星形:同样形成以父节点为中心的星形并收缩到父节点。保留超节点之间的所有边(包括平行边)。
  4. 递归处理:在收缩后的图上递归执行。
  5. 收集边:在递归过程中所有被选中的边(即子节点指向父节点所依据的最小权重边)的集合,就是原图的一个最小生成树。

为了在常数时间内找到每个节点的最小权重出边,我们使用“最小优先级CRCW”模型:为每条边分配一个处理器,这些处理器同时尝试将其权重写入关联节点的寄存器,最终该寄存器会保存所有写入权重中的最小值。

复杂度分析:与随机化连通分量算法类似,可以证明每轮期望有至少1/4的节点被收缩。因此,经过O(log n)轮后结束。每轮可在常数时间内完成最小出边查找和收缩,总时间为O(log n)。总工作量为每轮O(m+n),故为O((m+n) log n)

总结 📚

本节课中我们一起学习了PRAM模型上的几个核心图算法。

  1. 我们首先学习了如何为环图进行快速三着色,其时间复杂度仅为O(log* n),并利用该着色算法在线性工作量和O(log n)时间内寻找独立集。
  2. 接着,我们探讨了连通分量问题,介绍了两种图收缩方法:一种是随机化算法,通过构建星形图收缩,在O(log n)时间内完成;另一种是确定性算法,通过双向指针与指针跳转,在O(log² n)时间内完成。
  3. 最后,我们学习了最小生成树的随机化并行算法,它同样基于图收缩和最小出边性质,在O(log n)时间内找到MST。

这些算法展示了并行计算中解决图问题的核心思想:通过巧妙的对称性打破、迭代收缩和并行指针操作,在远快于顺序算法的时间内解决问题。

028:树上的PRAM算法

概述

在本节课中,我们将学习一系列在树结构上运行的并行随机存取机器(PRAM)算法。这些算法主要依赖于两种关键技术:欧拉回路技术和前缀和计算。我们将首先介绍欧拉回路的概念及其并行构造方法,然后利用这些技术解决树上的多个基本问题,包括树的定根、节点深度计算、后序遍历编号、子树大小计算、表达式树求值以及最近公共祖先(LCA)查询。这些算法旨在实现高效的时间复杂度(通常为O(log n))和线性工作量。

欧拉回路技术

上一节我们介绍了本课程将涵盖的算法主题,本节中我们来看看第一个核心技术:欧拉回路。

给定一个图,该图的一个欧拉回路是指一个经过图中每条边恰好一次的环。一个顶点可以被访问多次,但每条边只能经过一次。

例如,对于右侧的图,假设每条无向边实际上由两条方向相反的有向边组成。那么,该图的一个欧拉回路可以如图所示。在遍历这个回路时,我们恰好经过每条有向边一次。

一个著名的定理指出:对于一个连通有向图,当且仅当每个顶点的入度等于出度时,该图存在欧拉回路。在我们处理的无向树中,我们将每条边替换为两条有向边,这保证了每个顶点的入度等于出度,因此树必然存在欧拉回路。

现在,我们关注如何在树上并行地构造欧拉回路。目标是实现常数时间复杂度。

构造方法如下:

  1. 每个节点将其所有邻居按任意顺序排序。
  2. 欧拉回路本质上是一个循环链表。我们需要为每个节点确定其在回路中的下一个节点。
  3. 规则是:假设节点V有d个邻居。如果我们通过第i个邻居进入节点V,那么我们将通过第(i+1) mod d个邻居离开节点V。

例如,考虑节点9。假设它按顺序将邻居排序为[1, 3, 5]。如果我们通过第一个邻居(节点1)进入节点9,那么我们将通过第二个邻居(节点3)离开节点9。如果我们通过最后一个邻居(节点5)进入节点9,那么我们将通过第一个邻居(节点1)离开节点9。

通过为每个节点应用此规则,我们可以隐式地定义一个链表。这个链表连接起来就形成了树的欧拉回路。由于每个节点只需根据其邻居顺序执行一次常数时间操作,因此可以在常数时间内并行构造欧拉回路。

一旦我们有了这个隐式的链表表示,就可以对其应用链表上的前缀和算法(例如指针跳跃技术)。结合欧拉回路和前缀和,我们可以解决许多树上的问题。

树的定根

上一节我们介绍了如何构造欧拉回路,本节中我们来看看如何利用它来为树指定一个根节点。

给定一棵树,我们想要任意选择一个节点作为根。一旦根被确定,就会定义出一系列的父子关系。例如,如果节点1是根,那么节点2和节点3的父节点就是节点1。我们的目标是让每个节点计算出自己的父节点。

算法步骤如下:

  1. 为图中的每条有向边分配权重1。也就是说,每条无向边对应的两条有向边权重均为1。
  2. 在构造出的欧拉回路上计算前缀和。
  3. 对于每条边(U, V),如果从U到V方向的前缀和值小于从V到U方向的前缀和值,则判定U是V的父节点。

以下是一个具体的例子:

  • 考虑边(1,2)。从1到2的前缀和值为1,从2到1的前缀和值为12。因为1 < 12,所以我们判定节点1是节点2的父节点。
  • 考虑边(2,5)。从2到5的前缀和值为3,从5到2的前缀和值为10。因为3 < 10,所以我们判定节点2是节点5的父节点。

通过检查每条边,我们可以为所有节点确定父子关系。

由于欧拉回路的构造是常数时间,而前缀和计算可以在O(log n)时间内完成(其中n是边数),且总工作量为O(n),因此整个定根算法可以在O(log n)时间和O(n)工作量内完成。

计算节点深度

在将树定根之后,每个节点就有了确定的深度。例如,根节点深度为0,其子节点深度为1,以此类推。现在,我们希望每个节点能计算出自己的深度。

算法步骤如下:

  1. 首先使用之前的算法为树定根,使每个节点知道其父节点P(V)。
  2. 为边分配新的权重:
    • 从父节点指向子节点的边,权重设为+1。
    • 从子节点指向父节点的边,权重设为-1。
  3. 在得到的欧拉回路上计算前缀和。
  4. 对于节点V,其深度等于从父节点P(V)到V这条边上的前缀和值。

原理如下:在欧拉回路中,每次沿着向下的边(权重+1)移动,深度增加1;每次沿着向上的边(权重-1)移动,深度减少1。因此,从根节点开始累积的前缀和,在到达节点V时,恰好等于从根到V的路径上向下移动的次数减去向上移动的次数,即节点V的深度。

该算法同样需要O(log n)时间和O(n)工作量。

计算后序遍历编号

后序遍历的顺序是:对于一个节点,先访问其所有子节点,然后再访问该节点本身。我们希望为每个节点计算其后序遍历编号。

算法步骤如下:

  1. 为边分配权重:
    • 从子节点指向父节点的边(向上边),权重设为+1。
    • 从父节点指向子节点的边(向下边),权重设为0。
  2. 在欧拉回路上计算前缀和。
  3. 节点V的后序遍历编号等于从V到其父节点P(V)这条边上的前缀和值。

这个方法的原理是:在欧拉回路中,每次从一个子节点返回到其父节点时(经过一条权重为1的向上边),意味着我们完成了对该子节点及其后代的访问。因此,累积的权重(前缀和)恰好反映了在访问当前节点之前,已经访问过的节点数量(即其后序遍历编号)。

该算法的时间和工作量复杂度与前两个算法相同。

计算子树大小

对于一棵有根树,节点V的子树大小是指以V为根的子树中包含的节点总数(包括V自身)。例如,根节点的子树大小就是整棵树的节点数。

算法步骤如下:

  1. 使用与计算后序遍历编号完全相同的权重分配和前缀和计算。
  2. 节点V的子树大小等于 [从V到其父节点的前缀和值] - [从其父节点到V的前缀和值]

以下通过例子验证:

  • 要计算节点2的子树大小。从2到其父节点1的前缀和值为6,从1到2的前缀和值为0。因此子树大小为6 - 0 = 6。
  • 要计算节点3的子树大小。从3到其父节点1的前缀和值为7,从1到3的前缀和值为6。因此子树大小为7 - 6 = 1。

该算法同样在O(log n)时间和O(n)工作量内完成。

表达式树求值

表达式树是一种表示算术表达式的二叉树结构。其中,叶子节点是操作数,内部节点是运算符(如+、-、*)。我们需要并行地求值整个表达式树。

基本思路与Rake操作

顺序求值是从叶子节点向上计算,需要O(n)时间。并行算法使用一种称为“Rake”(梳理)的操作来逐步收缩树。

Rake操作针对一个节点进行:

  1. 移除该节点及其父节点。
  2. 将该节点的兄弟节点及其整个子树移动到原父节点的位置。

通过并行地对多个叶子节点执行Rake操作,可以快速减小树的规模。但必须确保并发的Rake操作不会修改同一个节点。策略是从树的底部开始,分批处理叶子节点。

并行Rake过程

  1. 标记所有叶子节点(除了第一个和最后一个),记为集合A。
  2. 为A中的叶子节点连续编号(1, 2, 3, ...)。
  3. 进行多轮处理,每轮:
    a. 找出A中编号为奇数的叶子节点。
    b. 先并行地Rake那些是“左孩子”的奇数编号叶子节点。
    c. 再并行地Rake那些是“右孩子”的奇数编号叶子节点。
    d. 经过一轮后,所有奇数编号的叶子被移除。剩余的是偶数编号叶子,重新为它们编号(1, 2, ...),并更新集合A。
  4. 重复上述步骤,直到树被收缩到只剩3个节点。

由于每轮移除约一半的叶子,所以总共需要O(log n)轮。

保持表达式值

在Rake过程中,我们需要保证收缩后的树与原始树的表达式值等价。为此,我们为每个节点V维护一个标签 (A_V, B_V) 和一个数值 X_V。节点的实际值计算为 A_V * X_V + B_V

当Rake一个节点时(例如,移除节点V及其父节点,提升其兄弟节点U),我们需要根据被移除节点的运算符和值,更新保留节点U的标签(A_U, B_U),使得新树中对应位置的值与旧树相等。这个更新涉及常数时间的算术运算。

算法复杂度

通过O(log n)轮的并行Rake,并将树收缩至3个节点,最后直接计算这3个节点的值即可得到原表达式树的值。每轮Rake和标签更新都是常数时间,因此总时间复杂度为O(log n),总工作量为O(n)。

最近公共祖先(LCA)

最近公共祖先问题是指:给定树中的两个节点U和V,找出深度最大的、同时是U和V祖先的节点。LCA有许多应用。

我们的目标是:允许对树进行预处理,预处理后,对于任意查询(U, V),能够在常数时间内回答它们的LCA。

简单情况

  1. 路径图:如果树是一条链,那么两个节点的LCA就是深度较小的那个节点。我们可以预处理所有节点的深度(O(log n)时间),查询时比较深度即可(常数时间)。
  2. 完全二叉树:如果树是完全二叉树,并且节点按中序遍历编号。设U和V的二进制编号从最高位开始第一个不同的位是第i位。那么它们的LCA的编号是:保留U和V在第i位之前的高位,第i位置1,后面的位置0。这也可以在常数时间内计算。

一般树的LCA算法

对于任意树,我们使用欧拉回路技术进行预处理。

  1. 预处理
    a. 构造树的欧拉回路,得到一个节点访问序列数组A(长度为2n-1)。
    b. 计算一个深度数组B,其中B[i]A[i]节点的深度。
    c. 对于每个节点V,计算其第一次和最后一次出现在A中的索引L[V]R[V]。这可以利用一个引理在常数时间内并行完成:L[V]=i 当且仅当 B[i-1] = B[i] - 1R[V]=i 当且仅当 B[i+1] = B[i] - 1

  2. 查询
    有一个关键引理:

    • 如果U是V的祖先,当且仅当 L[U] ≤ L[V] ≤ R[U]。此时LCA(U, V) = U。
    • 否则(假设R[U] < L[V]),那么LCA(U, V)是数组B在区间[R[U], L[V]]内具有最小深度值的节点所对应的A中的节点。

    因此,查询的关键转化为:在数组B的任意区间[i, j]内,快速找到最小值的索引。这是一个区间最小值查询(Range Minimum Query, RMQ)问题。

区间最小值查询(RMQ)

我们希望在预处理数组X(此处即深度数组B)后,能在常数时间内回答任何区间[i, j]的最小值。

预处理(O(log n)时间,O(n log n)工作量)

  1. 假设n是2的幂。构建一棵以X为叶子节点的完全二叉树。
  2. 对于树中的每个内部节点(H,J),它代表X的一个子区间[p, q]。我们为该节点计算两个数组:
    • P[H,J](前缀最小值数组):P[H,J][k] = min{X[p], X[p+1], ..., X[p+k-1]}
    • S[H,J](后缀最小值数组):S[H,J][k] = min{X[q], X[q-1], ..., X[q-k+1]}
  3. 这些数组可以从叶子节点开始自底向上合并计算。合并两个子节点的数组只需要常数时间(通过元素取最小值操作)。总共有O(log n)层,每层总工作量为O(n),因此总预处理时间为O(log n),总工作量为O(n log n)(可优化至O(n))。

查询(常数时间)
对于查询区间[i, j]

  1. 在完全二叉树中找到代表叶子ij的节点的LCA节点V。设V的左孩子为U,右孩子为W
  2. 确定iU所代表区间内的相对位置(从右往左数第p个),以及jW所代表区间内的相对位置(从左往右数第q个)。
  3. 区间[i, j]的最小值就是 min{ S[U][p], P[W][q] }
    由于完全二叉树的LCA、相对位置计算以及数组查找都是常数时间操作,因此RMQ查询是常数时间。

LCA算法复杂度总结

  • 预处理:构造欧拉回路和深度数组需要O(log n)时间。计算LR数组需要常数时间。RMQ预处理需要O(log n)时间。总预处理时间为O(log n)。
  • 查询:根据引理,一次查询需要常数次比较和一次RMQ查询,因此是常数时间。

总结

本节课中我们一起学习了多种在树结构上高效的PRAM算法。我们从欧拉回路技术及其常数时间构造方法出发,结合前缀和计算,解决了树的定根节点深度计算后序遍历编号子树大小计算等问题,这些算法均能在O(log n)时间和O(n)工作量内完成。

接着,我们探讨了更复杂的表达式树求值问题,引入了Rake操作来并行收缩树,并通过维护节点标签来保持表达式值,最终在O(log n)时间内完成求值。

最后,我们研究了最近公共祖先(LCA)问题。通过将LCA查询转化为欧拉回路序列上的区间最小值查询(RMQ),并利用完全二叉树结构在常数时间内回答RMQ,我们实现了在O(log n)预处理时间后,常数时间内回答任意LCA查询的算法。

这些算法展示了并行计算中如何利用简单的基础操作(如指针跳跃、前缀和)和巧妙的数据结构转换来解决复杂的图论问题。

029:MapReduce 🗺️➡️📊

在本节课中,我们将要学习一种近年来非常流行的并行计算模型——MapReduce。我们将探讨其设计动机、核心概念、工作原理,并通过具体实例来理解如何用它解决实际问题。

概述

MapReduce是一种专注于数据而非计算的并行编程模型,其核心目标是实现低成本和高可扩展性。它诞生于大约20年前互联网数据爆炸的背景下,旨在处理来自互联网和用户生成的海量数据,例如网页、用户信息、图像和视频等。除了互联网数据,MapReduce也可用于处理传统的科学计算数据,如物理或生物数据。

这种模型与所谓的“数据科学第四范式”紧密相关。传统科学方法依赖于收集有限数据、构建假设和复杂模型,然后通过更多数据验证模型。而数据科学方法则倾向于忽略模型构建步骤,直接收集尽可能多的数据,通过算法寻找模式,再由人工筛选出合理的模式来解决问题。MapReduce正是为支持这种大规模、简单计算的数据处理范式而设计的。

MapReduce非常注重成本和规模。它采用“横向扩展”而非“纵向扩展”的策略。这意味着系统通过连接大量廉价、性能一般的商用计算机和简单网络来构建,而非使用昂贵的高性能处理器和高速互连(如超级计算机)。因此,MapReduce非常适合在数据中心和云计算环境中运行。

最初由Google开发用于解决简单的文本处理问题,MapReduce因其能高效处理大规模数据上的简单计算而广受欢迎。由于其专有性,后来出现了开源的实现版本——Hadoop,它在工业界和学术界都得到了广泛应用。

MapReduce的设计哲学

在深入探讨MapReduce的具体设计之前,我们先了解影响其设计的几个关键假设。

硬件环境与容错性:MapReduce设计用于拥有数万台服务器的数据中心。即使单个服务器相当可靠,在如此庞大的规模下,组件故障也时常发生。因此,MapReduce被设计为能够处理组件故障。

计算向数据移动:MapReduce主要关注数据处理,计算通常很简单,有时甚至只需对数据流式扫描一次。因此,与其将海量数据在网络中移动,不如将计算代码(通常很小)发送到存储数据的服务器上执行。这避免了高昂的通信成本。

顺序数据访问:MapReduce作业通常只需要对数据进行顺序访问(例如,扫描一个数据块),而不需要跨多台计算机的随机数据访问。这减少了对通信网络的需求,而MapReduce通常运行在通信能力不强的网络上。

可扩展性:MapReduce旨在实现近似线性的扩展。如果处理器数量翻倍,处理速度或单位时间内处理的数据量也应大致翻倍。这通常是可以实现的,因为典型的MapReduce作业通信开销很小。那些需要大量通信或多步计算的算法并不适合在MapReduce上运行。

MapReduce模型详解

MapReduce模型基于一个简单的数据结构:键值对。模型中的所有计算都归结为处理这些键值对。

一个键值对 (K, V) 包含一个键 K 和一个与之关联的值 V。键可以是数字、字符串等任何用于标识值的类型,值可以是任意数据结构。例如,处理网页时,键可以是URL,值可以是网页内容;处理图时,键可以是节点ID,值可以是该节点的邻接表。

一个MapReduce计算包含两个主要步骤:Map(映射)Reduce(归约)

  • Map步骤:输入是一个键值对 (K, V)。Map函数将其转换并输出一个中间键值对的列表 [(K1, V1), (K2, V2), ...]。它可以从一个输入对产生多个输出对。
  • Reduce步骤:在Map步骤之后执行。输入是一个键 K 和与该键关联的所有值的列表 [V1, V2, ...]。Reduce函数处理这个列表,并输出另一个键值对列表 [(K’, V’1), (K’, V’2), ...]

程序员需要定义Map和Reduce函数的具体逻辑。

MapReduce系统执行流程

一个MapReduce作业的执行流程如下:

  1. 输入:作业的输入是一组存储在分布式文件系统中的文件,这些文件被解释为初始的键值对。
  2. Map阶段:系统将输入文件分发给多个Map服务器。每个Map服务器对其接收到的每一个键值对执行用户定义的Map函数,生成一批中间键值对。
  3. Shuffle与排序阶段:这是由MapReduce运行时系统自动执行的。它收集所有Map服务器产生的中间键值对,将所有具有相同键 K 的值分组到一起,形成 (K, [V1, V2, ...]) 的结构。然后,系统对这些分组后的数据按键进行排序,并将其分区,发送给不同的Reduce服务器。每个Reduce服务器接收到的数据在其内部是按键排序的。
  4. Reduce阶段:每个Reduce服务器对其接收到的每一组 (K, [V1, V2, ...]) 执行用户定义的Reduce函数,生成最终的键值对。
  5. 输出:Reduce阶段产生的最终键值对被写回到分布式文件系统中。

这个过程可以视为MapReduce的一个“周期”。在实际作业中,可以串联多个这样的周期,将上一周期的输出作为下一周期的输入。然而,由于每个周期(涉及文件I/O、网络通信、排序等)开销很大,典型的MapReduce作业只运行少数几个周期。

编程示例与模式

让我们通过几个具体例子来理解如何编写MapReduce程序。

示例1:词频统计 📊

问题:统计一系列文档中每个单词出现的次数。

  • Map函数:对于文档中的每个单词 t,输出一个中间键值对 (t, 1)。如果单词“cat”出现了3次,则输出三次 (“cat”, 1)
    # 伪代码
    def map(doc):
        for word in doc.split():
            emit(word, 1)
    
  • Shuffle/Sort:系统将所有相同单词的计数值 1 分组到一起,例如 (“cat”, [1, 1, 1, ...])
  • Reduce函数:对于每个单词 t 及其计数值列表 counts,将所有计数值相加,得到总次数 sum,然后输出最终结果 (t, sum)
    # 伪代码
    def reduce(word, counts):
        total = sum(counts)
        emit(word, total)
    

性能优化:Combiner与Partitioner

为了提升性能,MapReduce提供了可选的Combiner和Partitioner。

  • Partitioner:决定一个中间键值对应该被发送到哪个Reduce服务器。通常使用哈希函数(如 hash(key) mod RR是Reduce任务数)来实现负载均衡,前提是键的分布相对均匀。
  • Combiner:在Map任务本地执行的一种“迷你Reduce”操作。它可以在数据发送到网络之前,对Map任务输出的中间结果进行局部聚合。例如,在词频统计中,如果一个Map任务多次看到单词“cat”,它的Combiner可以先将这些 (“cat”, 1) 本地相加为 (“cat”, 5),再发送出去,从而减少网络传输和排序的数据量。

其他常见模式

  1. 过滤 🎯:仅保留满足某个条件的数据。只需使用Map阶段,检查每个输入对,满足条件则直接输出,无需Reduce阶段。
  2. Top-K查询 🏆:找出排名前K的记录。每个Map任务处理部分数据,找出本地的Top-K,并以一个公共键(如null)输出。单个Reduce任务收集所有Map任务的本地Top-K,再从中选出全局的Top-K。
  3. 去重 🔍:找出所有不同的值。Map阶段将每个值作为键输出(值设为null)。Shuffle阶段将相同键分组,Reduce阶段对每个键只输出一次。Combiner可以在Map端预先去重。
  4. 关系型连接 ⛓️:合并两个表的数据(以内连接为例)。Map阶段读取两个表的记录,以外键为键,记录内容和来源表标识为值输出。Shuffle阶段按外键分组。Reduce阶段收到同一个外键对应的所有记录列表,将其按来源表分开,然后执行嵌套循环合并,输出连接结果。

图算法示例

MapReduce也可用于迭代式图算法,但通常需要运行多个MapReduce周期。

示例:广度优先搜索 🕸️

问题:从源节点出发,计算图中所有节点的最短跳数。

  • 算法:这是一个类Bellman-Ford的算法。假设当前迭代已计算出距离源节点 d 跳以内的所有节点。
  • Map函数:对于每个节点 n 及其当前已知距离 dist(如果未知则为无穷大),遍历其所有邻居 m,为每个邻居发出 (m, dist + 1)
    # 伪代码
    def map(node_id, current_distance):
        for neighbor in get_neighbors(node_id):
            emit(neighbor, current_distance + 1)
        # 同时传递节点自身信息,确保不被遗忘
        emit(node_id, current_distance)
    
  • Reduce函数:对于每个节点 m,收到一系列声称的距离值,取其中的最小值作为 m 的新距离。
    # 伪代码
    def reduce(node_id, claimed_distances):
        new_distance = min(claimed_distances)
        emit(node_id, new_distance)
    
  • 执行:重复执行MapReduce周期,直到所有节点的距离不再变化。周期数等于图的半径(从源到最远节点的跳数)。对于直径小的图(如社交网络),此方法有效;对于直径大的图,则效率低下。

单源最短路径问题可类似解决,只需将Map函数中的 dist + 1 改为 dist + edge_weight

示例:PageRank算法 🌐

问题:基于网页链接结构计算网页的重要性排名。

  • 算法思想:一个网页的排名取决于链接到它的网页的数量和质量。通过迭代方式求解一个大型线性系统。
  • Map函数:对于每个网页 n 及其当前排名值 rank 和出链列表 L,计算它分配给每个邻居的排名份额 rank / len(L),并对每个邻居 m 发出 (m, rank / len(L))
    # 伪代码
    def map(page_id, current_rank, outlinks):
        share = current_rank / len(outlinks)
        for outlink in outlinks:
            emit(outlink, share)
        # 可选:也传递页面ID本身以维持图结构
        emit(page_id, outlinks) # 用于下一轮迭代
    
  • Reduce函数:对于每个网页 m,收集所有传入的排名份额,求和,并考虑随机跳转因子 alpha(用户随机访问一个网页的概率),计算新的排名:new_rank = alpha / N + (1 - alpha) * sum(incoming_shares),其中 N 是网页总数。
    # 伪代码
    def reduce(page_id, list_of_shares_or_structure):
        if is_structure(list[0]):
            # 处理图结构信息
            outlinks = list[0]
            emit(page_id, (outlinks, 0.0)) # 初始排名
        else:
            # 处理排名份额
            sum_of_shares = sum(list_of_shares)
            new_rank = ALPHA / TOTAL_PAGES + (1 - ALPHA) * sum_of_shares
            emit(page_id, (get_outlinks_from_previous_state(page_id), new_rank))
    
  • 执行:初始化所有网页排名为 1/N。重复执行MapReduce周期(通常几十到上百次),直到排名收敛或排名顺序稳定。Combiner可用于在Map端聚合发往同一网页的排名份额。

总结

本节课我们一起学习了MapReduce并行计算模型。我们了解了其面向大规模数据、简单计算、高容错和低成本的设计哲学。核心模型基于键值对,通过MapReduce两个阶段,并借助系统自动完成的Shuffle/Sort阶段来组织计算。

我们探讨了典型的编程模式,如词频统计、过滤、Top-K、去重和连接,并学习了使用CombinerPartitioner进行性能优化。最后,我们看到了MapReduce如何应用于迭代式图算法,如广度优先搜索和PageRank,同时也认识到其对通信密集或需要大量迭代步数的算法效率较低。

MapReduce为处理互联网时代的海量数据提供了一种强大而简洁的编程范式,深刻影响了大数据生态系统的发展。

posted @ 2026-03-26 13:11  布客飞龙IV  阅读(83)  评论(0)    收藏  举报