CMU-15-418-并行计算笔记-全-

CMU 15-418 并行计算笔记(全)

1:课程介绍与并行计算入门 🚀

在本节课中,我们将学习课程的基本信息、评分结构,并通过生动的例子初步了解并行计算的核心概念与挑战。

课程概述与人员介绍

我的名字是兰迪·布莱恩特,是15-418/618课程的讲师之一。今天和我一起的还有其他几位同事。这是我第三次教授这门课程。之前我曾与他人合教,但那位同事决定去阳光更充足的地方。因此,这次我邀请了格雷格·凯斯滕加入,他在克雷格街的信息网络学院工作。此外,托德·莫里将在本月的大部分时间里提供客座讲座。托德多年前创建了这门课程,并多次教授,他将在课程启动阶段提供帮助。

这是一门非常激动人心的课程,我们将深入进行并行计算的实践体验。我自己在两年前作为正式讲师时,完成了所有实验,并强迫自己不看答案,我发现这虽然困难,但让我学到了很多,对我非常有用。我相信你们也会有类似的体验。

课程注册与选课机制

教室里有很多人。这门课程有很长的候补名单。截至昨晚,有118名学生注册,但有172名学生在候补名单上。根据消防规定,这个教室最多容纳144人。因此,我们无法增加更多学生。这意味着目前有26个空位。

对于已经注册的学生,我希望你们真的想上这门课。如果你不确定是否有时间或可能负担过重,我真诚地希望你能做出承诺。虽然退课日期还很远,但如果你不打算坚持,请考虑在本月底退出,以便为候补学生腾出名额。为了班级的利益,我们虽然不能强迫你退课,但如果你还在犹豫,这将是一个好机会。

我们将使用第一次作业作为筛选机制。对于注册学生,虽然第一次作业在12天后才截止,但它很好地衡量了我们的期望和你们需要完成的工作量。我鼓励每个人都开始做这个作业,它能很好地校准这门课程的要求。

对于候补名单上的学生,这是你们展示自己的机会。作业已经发布。我们将查看一周后(1月24日)提交的所有作业,并进行非常活跃的评分。我们将根据这次作业的表现,决定谁能从候补名单中脱颖而出并注册课程。没有其他规则。这是一种非常严格的择优录取方式。我们不关心你在候补名单上的位置,不关心你是本科生还是研究生,也不关心这是否是你在卡内基梅隆大学的最后一个学期。这些因素都不会被考虑。决定因素100%基于这次作业的表现。

你们可以计算一下录取率。希望会有更多名额空出来。你们有一周的时间全力以赴完成这次作业,我们会看到结果。我无法向任何人承诺标准是什么,需要多好才能入选,我自己也不知道。但这就是门槛。所以,努力去做吧。没有隐藏的技巧,没有特殊交易,没有游说。我对送到家门口的巧克力或其他东西免疫,这些都没用。

课程内容与作业结构

这门课程是关于什么的?就你们要做的事情而言,你们会来听这样的讲座,但你们的大部分时间将花在完成作业和项目上。这是一门以实践为导向的课程,是一种相当极端形式的“做中学”。

具体来说,将有四次作业,每次大约持续两周。包括已经发布的第一份作业。有趣的是,这四次作业分别涵盖了计算机通过并行性实现加速的不同方式。这是这门课程的一个有趣方面:硬件制造商试图找出许多不同的方法来从他们能构建的硬件中榨取更多性能,他们通过提出非常不同类型的并行性来实现。因此,这门课程虽然涉及编写代码,但你必须理解和欣赏硬件才能有效地完成。

具体来说:

  • 第一次作业 将利用当今几乎所有处理器芯片中都包含的并行性类型,即多核,以及核内算术单元可以并行执行多条指令(称为SIMD,单指令多数据)。你们将使用英特尔研究人员开发的一种名为ISPC的语言来探索如何利用这两种并行性。
  • 第二次作业 涉及所谓的GPU或图形处理单元。GPU最初是为了加速图形处理而开发的技术,其主要市场是游戏机。一些聪明人发现可以创建专门的处理器来大幅加速图形处理,然后另一些聪明人意识到这种技术不仅适用于图形,于是开始使它们更具通用性,并使用不同的编程符号使其更可编程。现在,它们成为了从单芯片获得卓越性能的方式。
  • 第三次作业 将研究所谓的共享内存并行性。这意味着,如果你将多核的概念扩展到拥有一组处理器,它们都可以执行独立的程序,但它们共享一个公共内存,一个处理器写入的数据可以被另一个处理器读取。
  • 最后一次作业 将是解决相同类型的问题,但使用所谓的消息传递模型。同样,你有一个包含多个处理器的机器,但它们拥有独立的内存(或被视为拥有独立内存),如果它们想相互通信,必须显式地发送消息。

这两种模型,以及所有这些模型,都以各种形式内置在我们今天遇到的许多计算机中。所以,这是课程中非常重要的一部分。我想你会发现虽然会很忙,但如果你喜欢编程并喜欢让东西运行得更快,也会很有趣。

课程项目与评分

课程的另一个重要组成部分是期末项目。在这里,你可以设定自己的想法,决定你想做什么并从中获得收获。项目占你总成绩的四分之一,是一个重要部分。大致上,你应该将一个项目视为大约相当于两次作业的工作量。

项目范围很广,幻灯片中有一些链接展示了前两个学期的项目,可以给你一个概念。它们范围很广,例如:我有一个应用领域(如计算机视觉、图形学或分子模拟)可以从加速中受益,我想将其映射到我们已经研究过的某种类型的机器上并使其运行得更快,这是一个很好的项目类型。另一个是:我对课堂上讨论的锁定机制非常感兴趣,我想进行一些非常仔细的实验,比较它们在不同系统特性和运行数据下的表现。这是两种风格。

关于这个世界的一个有趣之处是,我们现在口袋里装的这些东西(手机)内置了很多并行计算能力。它们有多个处理器,也有GPU(虽然比英伟达的GPU更难编程,但可以做到)。因此,许多有趣的项目来自于在人们手机(特别是基于Android的系统)上运行的东西。所以,那里真的有很广泛的可能性。

我建议你从现在开始,直到你需要提出项目想法为止,留意各种想法。你对什么感到兴奋?有没有你想在课堂上更深入探索的东西?有没有来自这门课程之外的应用领域你想引入?这些都是值得思考的好事情。

测验、考试与评分细则

会有一些我称之为“测验”的东西,但这实际上只占你成绩的很小一部分。具体来说,我们会给你一些可以在考试前完成的带回家的问题,它们更像是考试准备材料,而不是我们真正关心你的答案。我们的评分基本上是“通过/不通过”。但这只是让你做好准备和思考的一种方式,因为正如你可能在其他课程中经历过的那样,如果你为一门课程编写了很多代码,然后参加考试,突然之间考试问题似乎与你实验中的体验大不相同。所以,这部分是为了让你思考如何看待问题并为考试做准备。

我们也会少量使用这些在线测验。我知道对我来说,我去年秋天在213课程中第一次使用它们,我认为它们是激励人们并实际上为讲师提供有用反馈以跟踪讲座材料进展的有用方式。这同样会在成绩中占一小部分。

所有这些加起来,总共只占这门课程100分中的3分。所以这不是你成绩的最大部分。它更多的是让你跟上并推进材料的方式。

以下是成绩分布(课程网页上有教学大纲,给出了所有细分):

  • 四次作业占 40%
  • 项目占 25%
  • 两次考试(课堂进行,时间表上有日期)占剩余部分。
  • 课堂测验等活动占 3%

作业和项目加起来占课程成绩的三分之二。这真的是主要焦点。考试将更多地涉及解决问题,推动课程的思想和概念,而不是你让代码运行和加速的能力。

还有一个关于迟交作业的方案(类似于213、513课程中的宽限期)。这有点复杂,无法在这里详细解释,但在网页上有相当清楚的描述。简而言之,有一个系统可以让你在提交作业时在时间安排上更灵活一些。

课程资源与协作政策

现在,昨天和今天,课程网页已经上线。有一个Piazza页面,任何人都可以注册账户,或者你基本上可以关注那个账户,使用我们的班级Piazza不需要做任何特别的事情。

这门课程没有教科书。教学大纲中给出了一些参考资料,但事实上,这个学科的一个特点是它发展得非常快,以至于教科书真的已经有些过时了。老实说,我们将使用这些幻灯片和演示材料,你在网上找到的材料将是课程的主要参考资料。

从第二次作业开始,你可以单独工作,也可以与另一个人合作。所以,你不必马上决定,我们不会在配对或帮助人们相处方面做任何事情,这完全取决于你。所以你可能在考虑可能的合作对象。

课程在周一、周三、周五上课。但在时间表上,你会看到有些课被指定为“复习课”。显然,这么大的教室不适合进行每个人都互动的复习课。但这些课的目的是针对一些作业,帮助你们掌握一些工具、理解和认识作业的要求。我认为去年我们尝试过,学生们觉得相当有帮助,所以我们也会采用某种形式。我们的想法不是呈现新材料,而是帮助你们更好地推进和适应作业。

最后两周,你们会看到没有课。我们试图做的是完成讲座材料,以便你们有时间弄清楚项目(其中一些将基于讲座材料),并腾出足够的时间,因为我们知道学期末学生们有多忙。我们希望你们在这些项目上付出相当大的努力,并确保你们有时间去做。在时间表中,你们会看到项目有多个检查点,我们计划积极跟踪你们的进展,这样你们就不会犯拖延到课程最后一周然后试图通宵完成一个不可能完成的项目这样的错误。我们希望这个项目对你们来说是一个巨大的成功,是你们多年后仍会记得的、真正伟大的经历。

学术诚信与有效协作

我想谈的最后一个部分是,我们不喜欢谈论但必须谈论的事情:什么样的合作是有效和无效的?同样,网页上有关于此的描述,也在教学大纲中。我们在这里说的没有什么不寻常的,但我想强调我们是认真的。

具体来说,这门课程中你们确实想互相交流。如果你们谈论的是高层次的想法,比如“我在想应该用这种方式还是那种方式并行”,这类问题是可以的。但我们不希望你们分享任何与如何实际实现代码的细节相关的内容,或者性能数据,或者互相帮助找出错误等。这些都不允许,真的不好。

另一方面,这门课程有很多材料,你们需要的很多资源都在网上。像英特尔和英伟达这样的公司有很多网页和数据,我们希望你们利用这些。Stack Overflow上有很多好东西,很多参考资料,人们的博客,网络上充满了材料。对于我们所有做这类工作的人来说,我们一直在网上搜索,试图弄清楚如何做X、Y或Z,或者这个的文档在哪里,我可以在哪里找到更多关于那个的信息。所以这是一个非常重要的工具,我们完全鼓励你们使用它。

另一方面,在人们的GitHub账户上,有作业的副本。如果你查看任何与这门课程现在或以前的实例相关的内容,那是绝对不允许的。如果你开始试图寻找别人的解决方案,你实际上会严重伤害自己。我告诉你,根据第一手经验,做这类事情是令人沮丧的。我的经验是:我尝试了一些我认为真的很棒的东西,结果在并行计算机上运行实际上比单核还慢。然后我不断努力,让它达到相同的速度。然后我继续努力,让它快了两倍。然后突然之间,它快了20倍。所以,这是一种你无法以某种线性形式衡量进展的事情。你有一个好主意,但结果行不通。然后你考虑另一个主意。你学习这些材料的方式就是通过实践。我们可以指导你,帮助你,试图避免一些死胡同或坏主意,但在很大程度上,这是你必须学会自己做的事情,因为这就是你进入世界后的方式。

所以,不要开始寻找GitHub账户和类似的东西。我说过有GitHub账户上有这些东西,但这并不意味着我们喜欢它。具体来说,我们认为,如果你将这些材料提供给未来的学生,无论是明确地还是无意地(因为你把它留在了GitHub账户上,因为你正在找工作,面试的公司说我们想看看你的代码,你能提供吗?你说当然,在GitHub上,看看吧),这是一种学术诚信违规。

如果这听起来像是可能真的发生过,那是因为它发生过很多次。在这种情况下,你是在向我们未来的学生提供材料,我们认为是学术诚信违规。我们可以而且实际上会追究前学生的责任。我们已经在其他课程中这样做过一点(不是这门课),我们全体教员打算现在对此更加严格。所以,你有义务以未来学生无法访问的方式保存自己的信息。

所以,这是课程中不那么有趣的部分,但却是需要理解的重要部分。

并行计算的历史背景

大家好,我是托德·莫里。正如兰迪所说,我教过这门课很多次,这是我最喜欢的课程之一。我现在要讲的是,我将从并行处理的历史以及我们如何发展到今天开始。

并行处理并不是新事物,它已经存在了几十年。它起源于一些人对快速计算有非常高需求的情况,例如物理学家试图模拟物理现象,他们需要大量数据,并且需要计算微分方程在多个时间步上的解。事实上,其中一些人在政府实验室工作,预算非常庞大,所以他们需要高性能,也有大量资金。人们开始使用多个处理器构建非常快的机器。事实上,第一台并行机器之一是在卡内基梅隆大学建造的,叫做C.mmp,我认为它的残骸实际上在韦恩大厅的某个地方。那是一台有16个处理器的机器。

另一个在80年代众所周知的例子是西摩·克雷,他设计了使用向量处理(我们下周会讨论)以及多CPU的超级计算机。他的机器非常昂贵且非常快,处理器数量相对较少,但每个都非常快。

然后有一家来自麻省理工学院的初创公司叫Thinking Machines,他们的机器设计非常不同。他们的方法是拥有大量非常弱的小处理器。例如,他们早期的一台机器中有65,000个单比特处理器。他们也非常擅长制造有很多闪烁灯的机器,这很适合演示,也适合科幻电影中人们想看到计算机在做某事。

最初,这完全是关于科学计算的,那些人仍然使用这些机器。但这类机器发展的下一步是,在20世纪90年代,数据库人员发现并行机器是进行事务处理的非常好的方式。他们弄清楚了如何将所有重要的算法和数据结构以及数据库分布在多个处理器上,这对于在线事务处理(例如,网络以及运行网站后端等的数据库)非常有用。例如,Oracle销售了很多运行数据库的机器,Sun Microsystems也是。Oracle是一家大型数据库公司,他们最近收购了Sun。所以,市场变得有趣的原因是,数据库市场比科学计算市场更大。突然间,这些东西作为实际业务变得更有趣了。

微处理器的发展与转折点

在谈论我们今天如何走到这一步之前,我想先谈谈通用微处理器随时间的发展。在你们出生之前,但在很多年里,处理器一直在呈指数级增长。例如,它们每三年大约快一倍。在计算机领域,我们有很多这样的指数曲线,例如内存变得指数级更大,磁盘变得指数级更大,等等。所以,这是另一个很好的指数曲线。

是什么让它变快?背后的原因是什么?一方面,它们有更宽的数据路径。同样,这在某种程度上是古老的历史了。很久以前,处理器一次只处理4位数据,然后发展到8位、16位、32位和64位。从16位到32位的跳跃在提高性能方面是一个大问题,32位到64位则不那么重要,大多数人用不到80亿字节的内存,当时的机器甚至没有那么多内存。所以,这是有所帮助的一点。

但另一个有很大帮助的是,在80年代和90年代,处理器设计者找到了更流线化的流水线设计方法,使得指令流经处理器时效率更高。他们使一切变得规整,这有助于将每条指令的执行时间从大约每条指令3.5个周期减少到接近每条指令1个周期。

在那之后的下一步是,他们希望超越这一点,开始让处理器同时执行多条指令。这今天仍在发生。其思想是硬件试图在指令流中向前看,找到独立的指令(彼此不依赖的指令),如果找到,它可能能够同时发出,比如最多四条指令。这在90年代尤其有助于提高性能。

但真正比其他任何东西都更有帮助的是时钟速率变得更快。时钟速率,有一个小晶体导致处理器在硬件中逐步执行其操作,时钟速率也在呈指数级增长。例如,在90年代初,处理器运行在大约10兆赫左右,然后在90年代,英特尔开展了一场巨大的营销活动,教每个人性能等于时钟速率。所以他们教所有消费者,你的100兆赫处理器现在是垃圾,因为我们的新200兆赫处理器已经出来了,所以你确实需要升级到新的200兆赫奔腾,等等。在很长一段时间里,时钟速率确实是驱动性能的主要因素,但后来发生了一些事情。

关于这个问题有一点预兆。例如,帕特·基辛格(他之前是英特尔的首席技术官,目前是VMware的首席执行官)在一次技术会议上发表演讲,在这个大事件发生之前,他谈到了……他展示了这张幻灯片,向人们展示了不同英特尔产品的功率密度数字。功率密度基本上是在芯片表面上每平方厘米产生多少瓦特。这很重要,因为当它变热时,你必须散热,否则东西会融化。在英特尔处理器的早期,你只有几瓦特,所以根本不热。但他指出,随着事物呈指数级改进,随着时钟速率上升,热密度也会上升。例如,英特尔奔腾处理器热得可以煎鸡蛋(我猜是一个小鸡蛋),它就像一个热板一样热。你可能听说过散热器这些东西,它们是处理器顶部的金属片,帮助散发其热量。这还不算太糟,虽然有点热,但展望未来,他说,好吧,时钟速率在呈指数级增长,但随着热密度开始继续呈指数级增长,我们芯片的表面将像核反应堆内部一样热,这听起来很热,很糟糕。然后我们将达到火箭喷嘴的水平,然后我们正在朝着达到太阳表面的热密度的路径前进。显然,有些事情可能会出错。

确实出错了。所以,大新闻是(这是2004年5月17日《纽约时报》的一篇文章),戏剧性的事件是:英特尔很清楚这个问题,但由于他们教每个人时钟速率等于性能,他们真的希望继续沿着时钟速率曲线前进,但他们不得不取消他们的两个旗舰项目,因为他们无法再解决如何冷却这些芯片而不使其熔化的技术问题。这是一件大事。实际上,我当时在英特尔工作。所以,之后的新世界是,既然我们不能让时钟速率更快,我们只能……不过,你能做的是在一个芯片上放置多个核心。世界一夜之间改变了。我知道那时你们大约在上一年级,所以这是很久以前的事了,但这就是为什么今天一切都与多核有关。

并行计算成为必然

如果你看这里的不同趋势,例如,时钟速率,就是这条曲线,它曾呈指数级上升,但已经趋于平稳。指令级并行性也已经趋于平稳。但有一件事没有趋于平稳,那就是芯片上的晶体管数量。所以我们有越来越多的硬件,但我们就是不能让单个处理器运行得更快。所以我们做的是在芯片上制造越来越多的处理器。

从软件人员的角度来看,这意味着什么?如果你想让你的软件运行得快,在旧时代(比如你出生的时候),你可以做的是,如果你想让你软件运行得更快,只需等六个月,买一台新机器,硬件就会运行得更快,一切都会更好。这很棒。但2004年后,世界改变了,现在买一台新机器,除非你实际改变你的软件以利用多核,否则它不会运行得更快,它会以相同的速度运行。这就是为什么这门课程很重要,因为我们将教你如何编写并行软件。

你们都很清楚这一点,但今天的每台计算机都是并行计算机,从高端机器一直到手机和手表等。我们今天有很多很多处理器。如果你看苹果的产品线,这范围从他们一些高端机器中的10个核心,到新手机(苹果X,我这里没有更新,但实际上我的稍旧手机有两个核心,而iPhone X有六个核心)。你的笔记本电脑和平板电脑也有很多核心。例如,英特尔的处理器有很多CPU直接印在芯片上,例如Skylake,它有许多CPU加上一个GPU,GPU本身可以用于进行大量处理,我们将在本课程中讨论,你们将在作业2中利用GPU。

还有另一个有趣的硬件,你们也将在本课程中使用。兰迪之前谈到GPU是并行计算能力的一个来源,但英特尔实际上做的是,他们采用了他们更老一代奔腾处理器的设计,并意识到那个东西非常简单和小,他们可以在一个芯片上制造很多个。所以有一种叫做Phi的东西,你们将使用它,它有60多个英特尔核心。

然后我们还将讨论本课程中的GPU。GPU有一种非常特定的执行模型,最初是为图形处理设计的,对于某些事情效果很好。所以,如果它很好地映射到你的问题,它可能是获得良好性能的非常好的方式,它们有很多很多计算单元。你在这里看到的每个小方块都是一个处理器,所以这些东西通常有数千个计算单元。

像手机和平板电脑这样的东西也有多个处理器。在这些设备中,我们通常谈论的是两到六个处理器,不是数千个,但它们也有GPU。所以这是另一个并行性来源。最后,为了好玩,如果我们看看真正的高端,这是今天美国橡树岭最快的超级计算机之一,它有18,000个16核处理器和18,000个GPU,所以它有数十万个处理器。总之,在当今世界,我们有很多很多并行机器。那么我们如何利用它们?我们如何利用它们?

并行计算的定义与挑战

在这门课上,我们将讨论并行性和并行计算。那么,什么使并行计算机与非并行计算机不同?是什么让并行计算机不同?是的,它里面有多个计算机。现在,你们每个人至少有一部手机,我看到一堆笔记本电脑等。所以在这个房间里,我们有很多很多不同的手机和笔记本电脑。这个房间里所有的计算机,它们是一台并行计算机吗?嗯,集体来说。所以我们这里有一台巨大的并行计算机。不一定,也许。所以,不仅仅是拥有一堆处理器。这是一个教科书式的并行计算机定义:它确实有多个处理元素,这是显而易见的部分,但使其有趣和具有挑战性的部分是,它们实际上不仅需要存在,还需要合作,需要共同工作以更快地解决某个单一问题。所以,并行性是关于利用许多处理器的力量使事情运行得更快,但这并不容易做到,而这正是本课程的重点。

互动演示:理解并行效率

现在,我们准备做一些并行编程,但接下来的四到五讲都将涉及与并行软件相关的问题。在那之前,我今天要做的是,我们将进行一些人类计算。在剩余的大部分时间里,我们将与来自听众的志愿者一起进行演示,信不信由你,我们将从这些演示中学到很多关于并行软件如何工作的知识。

我需要第一个演示的志愿者,唯一需要的技能是你能加一位数。我需要一个志愿者,有人能来这里吗?我将要求很多志愿者,所以你可能想早点完成志愿工作。太好了,谢谢。嘿,你叫什么名字?萨米?萨姆?我这里有……等一下。我要做的是,我要计时。但别担心,没有对错。嗯,答案有对有错。但我要做的是,我想看看需要多长时间。我们将以此作为我们的起点。我们将让萨姆把16个数字加起来,我们只是计算这需要多长时间。然后我们将开始并行地做这件事。这只是为了获得一个基准数字。那么,当你准备好了,你可以继续把它们加起来,6和9下面有横线。好的,开始吧。哦,心算,对不起,我意识到。好的。六十。对了,好的,好的,太好了,好的,谢谢。好的,那花了56秒。好的,所以这是我们计算的顺序版本,我们今天的计算是加16个数字。

现在让我们开始并行地做这件事。首先,我们将使用……我要拿一组不同的数字,这些都是一位数的集合。好的,那么,这次我们将用两个处理器来做,我需要一个来自后面某个地方的志愿者,我需要一个志愿者到教室前面来,首先,有人能上来吗?好的,太好了。嘿,你好吗?你叫什么名字?哦,很好,很高兴见到你。好的,所以我有了……等一下。我有一组数字要你加起来,我需要另一个志愿者,来自……更靠教室前面的。好的,太好了。你好吗?你叫什么名字?嘿,弗兰克。所以,弗兰克,我想让你站在这里,等我告诉你开始时,你开始加它们(还没开始),我想让你在这里把它们全部加起来,当你完成后,你可以上来。目标是,我们想知道总数,我给了你们每人一半的数字(每人8个一位数),唯一允许你们相互交流的方式是在这张纸上写字,所以你们不能互相交谈。所以,当你完成后,你可以上来写,你可以写,我们会得到总数。好的,那么,你准备好开始了吗?好的,开始。是的,对了,是的,好的,谢谢。好的,所以。你们可以坐下了,谢谢。那花了34秒。所以,34比56快,所以事情变快了。理想情况下,如果你有两倍多的计算机在工作,你希望这个计算快多少?也许快两倍,对吧?它快了两倍吗?不完全是,比两倍快慢一点。这在我们做并行软件时相当典型。我想指出的一点是,当我们谈论事情变快时,我们会使用一个数字,我们称之为加速比

计算加速比的方式是:在一个处理器上花费的时间(在我们的例子中是56)除以在多个处理器上花费的时间(在这种情况下是两个)。所以我们有两个。那个数字是34,所以这个数字是……如果有人有计算器,你可以算出来,但大约是1.8左右。所以我们的加速比大约是1.8。

我们在这里注意到了什么?为什么没有快整整两倍?你们看到了一些低效的原因吗?是的,跑下楼梯的时间。完全正确。我让弗兰克从那里开始然后走到这里的原因是为了使这更符合实际处理器的实际情况,因为即使它们在同一个芯片上,当它们想相互通信时,它们必须通过电线传输信息,这实际上需要时间,所以有点像必须跑上来。你可能注意到的另一件事是,即使弗兰克上来了,他们还没有完成,他必须实际看看纸上有什么,加上那些数字并得到结果。这些是我们效率有点低的原因。

那很有趣,但现在我们要做两个处理器很好,但四个更好。所以现在我需要四个志愿者,因为我们要扩大规模。所以请四个人下来,那太好了。好的,这是一个。好消息是,当你做这个时,你需要加的数字更少。所以下来吧,我知道,我知道你被卡内基梅隆大学录取了,我相信你能处理这里的计算任务。太好了,好的,那是两个,好的,这边有人。也许格雷格可以帮我找人。好的。太好了,好的,那边有个楼梯,谢谢。太好了,谢谢。

好消息是,我不会让你们任何人站在上面,你们都可以聚集在桌子周围。我马上要做的是,我拿16个数字,把它们分成几堆,如果你们稍微分散一点,你们每人马上会得到一堆。所以是同样的交易,你们不能互相交谈,你们要把数字加起来,用那支笔和纸,你们要得到总数。让我在这里分发我的数字堆。好的,先别开始,等我一下。好的,我计时。准备好了吗?开始。等等,我丢了牛。好的,好吧,我们就假装你刚刚完成那里。好的,好吧,想象你刚刚完成那个。好的,别走,我们要……好吧,让我们为这里勇敢的志愿者们鼓掌。好的,那么,现在用四个处理器,花了39秒。好吧,这没有比那个快四倍,是吗?那么,发生了什么?你观察到这里发生了什么?为什么花了一段时间?是的,有一个瓶颈。是的,有一个瓶颈。具体来说,我对我们穿黑衬衫的朋友做了什么坏事?是的,功能失调。是的,你注意到我给他的数字比任何人都多得多吗?是的,所以我实际上总共有16个数字,但他最终拥有的远不止四个,实际上多得多。那么,我为什么这样做?因为实际上这在并行软件中经常发生,也就是你有工作要做,提前均匀地分配并不总是容易的。所以可能出错的一件事是,如果工作分配不均衡,一些处理器可能已经完成,就像你看到左边的三个人基本上完成了,然后他们在等待,而他们等待时,他们没有任何有用的事情可做,你必须等到最后一个完成才能继续,这是花费那么长时间的主要原因。你注意到其他任何低效来源吗?是的,所以不仅像以前一样两个人必须写东西,现在你们所有人都必须写东西。所以,你知道,我们必须去拿笔,然后加上所有那些数字。所以这是另一种瓶颈。

那么,好的,如果你们能留在这里,我们要再做一次,但这次我们要用不同的基本规则,也就是这次,我也要给你们数字堆,可能又不均匀地分配,但我要让你们想出一个更好的方法来做这件事,所以特别是,你们不限于只加我最初给你们的数字,所以也许你们四个可以互相交谈一会儿,想出一个策略,试图这次做得更快一点。所以,你们不能……是的,你们不能说话,但如果你们想,你们可以移动数字,你们现在可以说话,所以现在想,弄清楚你们可能做什么让它更快。我们有多少次尝试?好吧,我们只做这一次,那就是了。但也许你们可以在这个过程开始时协调一下。所以当我说开始时,你们实际上被允许……我们可以移动东西吗?但你们就是不能说话,只有16个数字。好的。好的,那么,你们准备好了吗?好的,我要分发这些,所以先别开始,但我会把堆放在你们面前。好的。好的,那么,准备好了吗?开始。哦,好的。太好了。那是……答案是什么?好的,那花了24秒,所以那实际上快了很多。那么,这次你们的策略是什么?我们看到了一点,但对于后面房间的人来说,我们可能没有看到,所以你们这次做了什么不同的事?我们喜欢把它分成四个数字。是的,只有一个人总计。是的。太好了,是的,所以那让它快了很多,总之谢谢你们志愿,你们可以回去坐下了。

所以,我们在那两个演示中看到的是,负载不平衡确实是主要挑战之一。有很多挑战,但真正导致性能下降的事情之一是,如果你没有均匀的工作量。所以在第二次尝试中,他们基本上重新平衡了工作,这样他们每人加四个数字,然后这帮助很大。现在一件事是,做那个重新平衡确实花了一点时间,所以这是一个额外的开销来源,但在这种情况下,它回报相当大,明显快了很多。

大规模并行演示与通信开销

现在你们所有人都在志愿,我们现在要进行一次大规模并行计算。我想知道的是,我想知道现在房间里你们每个人注册的课程总数,希望这些是单位数。好的,所以房间里大约有150人,所以我们现在要加大约150个数字。这里有一些基本规则:你只能与你的邻居交谈(左边、右边、前面或后面),不能在整个房间里大喊,可能也不会很好。那么,你准备好了吗?哦,不,你还没准备好。那么,我们怎么做?有什么想法、建议吗?是的,每个人沿着行传递累积的总和,在行的末尾,传递。好的,所以也许像这样在房间里横向相加,然后取那些数字,纵向加到前面,所以也许我们在这里得到一个答案,还有其他想法吗?是的,从两端开始。很好,那样你们就可以在同一行同时相加,所以也许从每一端向中间加,然后当它到达中间时,向前传递,然后有人可以告诉我总数。好的,那么,我们加了16个数字用了56秒。所以一个处理器大约是每个数字几秒。现在我们要加150个数字。大约是10倍多的数字,但我们有150个处理器。所以应该非常快。好的,那么,大家都准备好了吗?好的,那么,你知道算法,你要向中间加,然后中间的人要向前加。好的,好的,各就各位,预备,开始。你注册了多少门课?所以我们在加你注册了多少门课,对吧?好的。一百不,不,不,不。好的,太好了,谢谢。但那是错误的答案。不,我开玩笑的。我没有办法检查那个。好的,那花了95秒。所以,好吧,使用同样的比率,我们本应期望一个更快的数字,那有点慢,对吧?考虑到我们这里有150个处理器,你们每个人应该能够像每几秒加一个数字一样加数字,而我们只有……事实上,我们有150个数字,所以应该花了大约三、四秒来加所有这些数字,对吧?仅仅基于你们集体加数字的能力。类似这样。那么,为什么花了那么长时间?对我们来说太花时间了。对,是的,完全受限于通信,所以在95秒中,你有多少时间在做任何有用的事情?是的,比如几乎……你大部分时间都在坐着,所以那是一个我们基本上在整个房间进行大规模规约的情况,你必须……完全受限于通信,这就是它那么慢的原因。好的。

课程核心主题总结

实际上,在接下来的讲座中,当我们讨论并行软件时,我们将回顾我们在这些例子中看到的几件事。但总结一下,我将谈谈课程中的一些主题。其中之一是,我们希望你们学习如何编写可扩展的并行程序,即用更多处理器运行得更快,你们需要知道如何均匀地划分事物。事实证明,还有很多其他问题需要考虑,这是你们在四人演示中看到的一个,但它甚至比那更有趣得多。所以我们将讨论思考并行性的不同方式。事实上,正如兰迪所说,每次作业都有一个完全不同的抽象来思考并行性。

这门课程的下一件事是,你们需要了解一些并行硬件的工作原理,因为即使你们对硬件完全没有兴趣,事实证明,与在普通顺序机器上编写软件不同,当你试图在并行机器上让东西运行得更快时,硬件在底层做什么的细节对性能有非常大的影响。因此,出于这个原因,你们确实需要知道一些关于什么会限制性能、什么会导致瓶颈的重要事情,即使你们不打算设计硬件,这也是你们需要知道的。

班级的另一个重要主题是效率。目标不仅仅是快,还要高效。我们希望你们高效地使用计算资源。例如,一个问题是,如果你在上班或暑期实习,被要求并行运行这个问题,它在10个处理器上运行快了两倍,你的老板会给你大幅加薪还是解雇你?或者介于两者之间?但这会被认为是好是坏?是的,那要看情况。这是正确的答案。所以,可能你无论如何都有那10个处理器,比如想想你手机上的GPU,你反正有它。所以,如果你有资源,而你的软件没有达到实时帧率,通过让它快两倍现在达到了,那是一个巨大的成功,真的很好。但如果你在亚马逊网络服务上租用时间,你为10台机器付费,但你只从中得到一点点增益,那么也许这不是一件好事。但就你能高效而言,这很重要。对于硬件设计者来说,这也是非常重要的事情。所以,特别是在2004年这个大变化之前,如果我们继续提高时钟速率,一切都会开始融化,那时的游戏规则是,你得到一个芯片,只放一个处理器在上面,尽可能让它快,所以事情变得越来越投机、激进和低效。事实证明,只是为了挤出越来越多的性能。但今天,一切都是关于效率,因为一旦你开始在一个芯片上放置多个核心,每个处理器的面积就很重要,因为如果它更小,我们可以在上面放更多。所以,如今一个非常重要的指标是效率,即处理器单位面积的性能,能源效率也非常重要,因为对于移动设备(电池寿命很重要)和云中的服务器仓库(电力也很重要,因为建筑物只能获得这么多电力)都是如此。

总结

基本上,这是这里的最后一张幻灯片:单线程性能真的预计不会变得更快,所以一切都与并行性有关。这不是一件容易的事,这意味着好消息是,一旦你完成这门课程,与世界上的其他人相比,你将拥有一个秘密武器,那就是你实际上会理解这些问题。欢迎来到这门课,就这样。


本节课中我们一起学习了:课程的基本结构、评分方式、学术诚信政策,以及并行计算的历史背景、核心挑战(如负载均衡、通信开销)和基本概念(如加速比)。通过互动演示,我们直观地理解了编写高效并行程序所面临的实际问题。

2:并行计算的硬件视角 🖥️

在本节课中,我们将从硬件角度探讨并行计算。我们将从你熟悉的笔记本电脑或台式机中的传统处理器开始,然后展示其如何被调整和修改,以创造出当今并行计算中非常重要的一类机器——图形处理单元(GPU)。本节课的核心主题是,为了利用并行性,硬件设计者在体系结构的不同层次上设置了并行计算的潜力,其中一些对程序员是透明的,而另一些则需要程序员或编译器显式生成相应的代码。因此,你需要充分理解硬件,才能编写出能让硬件发挥最大潜力的软件。

回顾与背景

上一节课我们讨论了2004年左右发生的变化,即芯片功耗墙的问题。一旦芯片功耗超过约100瓦,散热就变得极其困难。这彻底改变了计算机的发展方向,未来的性能提升将主要依赖于并行性。

我们还通过一个并行加法的物理演示,看到了并行编程中的一些挑战,如负载均衡、通信延迟以及集体工作时可能出现的利用率不足问题。

并行性示例:计算正弦值

让我们通过一个简单的代码示例来理解并行性。假设我们需要计算一个数组中每个元素的正弦值,使用泰勒级数展开。核心循环如下:

for (int i = 0; i < n; i++) {
    float x = A[i];
    float term = x;
    float sum = term;
    for (int j = 1; j < num_terms; j++) {
        term = -term * x * x / ((2*j) * (2*j + 1));
        sum += term;
    }
    B[i] = sum;
}

这个循环的每个迭代 i 都是完全独立的,不依赖于其他迭代的结果。这种计算有时被称为“易并行”计算,意味着你可以根据可用资源尽可能多地并行执行这些独立任务。

从顺序执行到指令级并行

在传统的顺序执行模型中,处理器一次执行一条指令。然而,自20世纪90年代末以来,微处理器普遍采用了超标量架构,能够从单个指令流中提取并行性,这被称为指令级并行

然而,在我们上面的代码中,指令之间存在数据依赖关系(例如,必须先读取内存才能进行乘法运算),这限制了ILP的发挥。为了克服这一点,现代处理器采用了乱序执行技术,通过复杂的硬件逻辑动态分析指令间的依赖关系,并调度到多个独立的功能单元上并行执行,同时保持程序语义不变。

多核与线程级并行

由于功耗墙和ILP提取的限制,硬件设计转向了多核架构。与其制造一个庞大、复杂且高功耗的单核,不如将芯片面积用于制造多个性能稍低但能并行工作的核心。

但是,要让一个纯顺序的程序在多核上运行得更快,我们需要将其分解为可以并行执行的多个部分。一种方法是使用线程,例如Pthreads。我们可以将数组分成两部分,让一个线程处理前半部分,主线程处理后半部分。

更理想的情况是,我们有一种编程语言或抽象,能够直接表达这种“易并行”的计算意图,例如一个 parallel for 循环。这样,编译器和运行时系统可以自动将其映射到多个线程或多个核心上执行。

单指令多数据(SIMD)并行

除了在“垂直”方向上将任务分给多个线程(线程级并行),我们还可以在“水平”方向上并行处理数据。这就是单指令多数据 并行。

SIMD允许一条指令同时对多个数据元素执行相同的操作。例如,一条加法指令可以同时将两个包含8个浮点数的向量相加。在x86架构中,这通过AVX 等扩展指令集实现。

对于我们的正弦计算循环,我们可以使用SIMD指令同时处理8个 x 值。但这通常需要显式地使用编译器内部函数 来编写代码,或者依赖编译器进行自动向量化(后者对于结构良好的简单循环可能有效,但通常需要很多提示和条件)。

SIMD中的条件分支处理

如果循环内部存在条件分支(例如 if (x > 0)),SIMD如何处理?技术是使用掩码。对向量中的所有元素执行条件测试,生成一个真假掩码。然后,执行“真”分支的操作,但只对掩码为真的元素生效(通过禁用其他元素的写入)。接着,反转掩码,执行“假”分支的操作。这种方式将条件分支“扁平化”为顺序代码,但可能导致效率下降,因为部分ALU在某些步骤中可能闲置。

SIMD执行效率的关键概念是一致性:所有数据元素是否执行相同的操作路径。一致性越高,SIMD利用率越高。反之,则称为发散,会降低性能。

图形处理单元(GPU):大规模并行

GPU将“多核”和“SIMD”的思想推向了极致。它移除了传统CPU中复杂的乱序执行、分支预测等控制逻辑,将芯片面积主要用于增加大量的、相对简单的计算核心,从而在单位面积内提供极高的算术逻辑单元 密度。

GPU的编程模型常被称为单程序多数据:许多线程(成百上千个)执行相同的程序(内核函数),但处理不同的数据。在底层,GPU使用宽SIMD(例如32个操作同时进行,称为一个线程束)来实现这些线程的高效执行。

为了隐藏内存访问延迟,GPU采用了极致的多线程技术。每个流多处理器可以同时管理数十个线程束的状态。当一个线程束因内存访问而停顿时,硬件会立即切换到另一个就绪的线程束,从而保持计算单元的繁忙,实现高吞吐量

内存带宽:关键的瓶颈

无论是CPU还是GPU,一个常见的性能瓶颈是内存带宽。计算单元的速度可能远远超过将数据从内存传输到芯片的速度。例如,一个执行简单向量加法的内核(每个浮点运算需要读取两个数并写入一个数)可能完全受限于内存带宽,计算单元利用率很低。

因此,在并行编程中,优化内存访问模式、提高数据局部性、利用缓存以及有时甚至选择重新计算而非存储中间结果,都是至关重要的策略。

总结

本节课我们一起学习了并行计算的硬件基础。我们回顾了从顺序执行到指令级并行(ILP)的演进,探讨了通过多核实现线程级并行(TLP)的动机。我们深入了解了单指令多数据(SIMD)并行,包括其工作原理以及处理条件分支的掩码技术。最后,我们介绍了图形处理单元(GPU)作为一种极致的大规模并行架构,其设计哲学是牺牲单线程性能以换取极高的吞吐量和计算密度,并特别依赖于硬件多线程来隐藏内存延迟。

关键要点在于,现代硬件在多个层次上提供了并行性:指令级、数据级(SIMD)、线程级(多核)以及GPU中的大规模线程级。要编写高效的并行程序,必须理解这些硬件特性,并据此组织你的计算和数据访问,以充分利用硬件的潜力,同时避免由内存带宽和延迟带来的瓶颈。

3:并行软件抽象与实现

在本节课中,我们将学习并行编程的核心抽象概念及其实现方式。我们将首先通过一个具体的语言(ISPC)来理解抽象与实现之间的区别,然后探讨三种主流的并行编程模型:共享地址空间、消息传递和数据并行。


抽象与实现:以ISPC为例

上一节我们介绍了并行硬件的基础。本节中,我们来看看如何编写并行软件。今天的核心主题是理解抽象实现之间的区别。

  • 抽象是程序员用来更轻松地表达代码、进行调试的工具。
  • 实现是系统在底层实际执行这些抽象概念的方式。

在并行计算中,很容易混淆这两者,因为抽象用于表达并发和通信,而系统本身也在管理并发和通信。我们将通过一个具体的例子——Intel开发的并行语言ISPC——来阐明这一点。

SPMD模型

ISPC采用了一种称为SPMD的抽象模型,即单程序多数据。这意味着所有并发执行的逻辑单元(可以理解为“线程”)都在运行相同的代码,但各自操作不同的数据片段,从而获得并行性。

以下是一个计算正弦值的串行C代码示例,我们将用它来演示并行化:

void sinx(int N, int terms, float* x, float* result) {
    for (int i=0; i<N; i++) {
        float value = x[i];
        float numer = x[i] * x[i] * x[i];
        int denom = 6; // 3!
        int sign = -1;
        for (int j=1; j<=terms; j++) {
            value += sign * numer / denom;
            numer *= x[i] * x[i];
            denom *= (2*j+2) * (2*j+3);
            sign *= -1;
        }
        result[i] = value;
    }
}

ISPC程序结构

在ISPC中,并行代码被放在扩展名为.ispc的独立文件中,并由主程序(如main.cpp)调用。其抽象模型如下:

  • 主程序顺序执行,直到调用一个ISPC函数。
  • 此时,系统会生成一个程序实例组来并发执行该ISPC函数。
  • ISPC函数执行完毕后,控制权返回主程序,恢复顺序执行。

在ISPC文件中,有两个关键的内建变量:

  • programCount: 当前组中并发程序实例的总数(由系统决定)。
  • programIndex: 当前程序实例的索引(从0到programCount-1)。

为了实现并行化,我们需要重写循环,让每个实例处理不同的数据。以下是并行化的sinx函数:

export void sinx_ispc(uniform int N, uniform int terms, uniform float* x, uniform float* result) {
    // 假设 N % programCount == 0
    for (uniform int i=0; i<N; i+=programCount) {
        int idx = i + programIndex;
        float value = x[idx];
        float numer = x[idx] * x[idx] * x[idx];
        uniform int denom = 6; // 3!
        uniform int sign = -1;
        for (uniform int j=1; j<=terms; j++) {
            value += sign * numer / denom;
            numer *= x[idx] * x[idx];
            denom *= (2*j+2) * (2*j+3);
            sign *= -1;
        }
        result[idx] = value;
    }
}

注意代码中的uniform关键字,它是一个优化提示,表示该变量在所有程序实例中具有完全相同的值。

数据分配策略:交错访问与分块访问

在SPMD模型中,如何将数据分配给各个程序实例是关键。主要有两种策略:

  1. 交错访问:每个实例以programCount为步长跳跃访问数据(如上例所示)。例如,4个实例会分别处理索引为0,4,8...、1,5,9...等的数据。
  2. 分块访问:将数据分成连续的块,每个实例处理一个块。

以下是分块访问的代码示例:

export void sinx_blocked(uniform int N, uniform int terms, uniform float* x, uniform float* result) {
    uniform int count = N / programCount;
    int start = programIndex * count;
    for (uniform int i=0; i<count; i++) {
        int idx = start + i;
        // ... 计算 sin(x[idx]),与之前类似 ...
        result[idx] = value;
    }
}

对于ISPC而言,交错访问通常是更优的选择。原因在于ISPC的底层实现。

ISPC的底层实现与foreach原语

ISPC的抽象(程序实例组)在底层是通过SIMD向量指令实现的。编译器会将ISPC代码编译成使用这些指令的单线程程序。

  • programCount通常对应于机器的向量宽度。
  • 在交错访问模式下,一次向量加载指令可以连续地读入内存中相邻的数据,效率极高。
  • 在分块访问模式下,要同时加载不连续的数据(如0,4,8,12),需要使用速度较慢的向量聚集指令

为了让程序员无需手动选择策略,ISPC提供了foreach原语。程序员只需指出循环迭代可以并行,系统会自动选择最高效的方式(通常是交错访问)来合成代码。

export void sinx_foreach(uniform int N, uniform int terms, uniform float* x, uniform float* result) {
    foreach (i = 0 ... N) {
        float value = x[i];
        // ... 计算 sin(x[i]) ...
        result[i] = value;
    }
}

规约操作

当需要跨实例计算全局和(规约)时,不能简单地将一个变量声明为uniform并让所有实例累加。这会导致数据竞争。正确的做法是让每个实例先计算局部和,然后使用特殊的规约操作符(如reduce_add)合并结果。

export uniform float sum_array(uniform float* input, uniform int N) {
    float local_sum = 0;
    for (uniform int i=0; i<N; i+=programCount) {
        local_sum += input[i + programIndex];
    }
    float total_sum = reduce_add(local_sum); // 跨实例规约
    return total_sum;
}

任务并行

上述ISPC代码仅利用了一个核心的向量单元。为了利用多核,ISPC引入了任务的概念。任务可以被调度到不同的核心上执行,实质上是操作系统线程。


三种主要的并行编程抽象

理解了抽象与实现的区别后,我们来看看三种广泛使用的并行编程模型。它们的核心区别在于并发实例之间如何通信协作

1. 共享地址空间 🧠

在这种抽象中,所有线程共享一个全局的、统一的地址空间(内存)。

  • 通信方式:线程通过读取和写入共享变量进行通信。这类似于多个人在同一块白板上读写信息。
  • 同步需求:由于存在并发访问,必须引入同步机制(如锁、屏障)来确保数据一致性和操作顺序。
  • 硬件实现示例
    • 对称多处理:所有处理器通过总线或交叉开关平等地访问同一内存。
    • 非均匀内存访问:每个处理器拥有部分本地内存,访问本地内存快,访问远程内存慢。现代多路服务器和大型机(如SGI Altix)采用此架构。
  • 挑战:需要硬件支持缓存一致性,以确保所有处理器看到的内存视图是一致的。

2. 消息传递 📨

在这种抽象中,每个线程只拥有私有地址空间,没有共享内存。

  • 通信方式:线程通过显式地发送和接收消息进行通信。发送方指定数据地址和接收方ID,接收方等待并处理消息。
  • 同步:通信操作(发送/接收)本身通常就蕴含了同步。
  • 硬件实现示例:任何具有网络的计算机集群都可以实现消息传递。高性能机器(如IBM Blue Gene)使用定制的高速互联网络。
  • 优势:无需复杂的硬件缓存一致性支持,易于构建超大规模系统。
  • 灵活性:消息传递的抽象可以在共享地址空间硬件上高效实现(例如,通过传递指针而非复制数据)。反之,在无硬件支持的机器上模拟共享地址空间则性能较差。

3. 数据并行 🔄

这种抽象适用于对大量数据应用相同操作的场景。

  • 核心思想:将计算映射到数据集合上。计算被表达为作用于数据流的纯函数(内核)。
  • 编程模型:如流编程。数据被组织成流,通过一系列无副作用的核函数进行处理。编译器或运行时系统负责安排并行执行。
  • 关键原语
    • Gather:从内存的非连续位置收集数据到连续缓冲区。
    • Scatter:将连续缓冲区的数据分散到内存的非连续位置。
  • 硬件实现示例GPU是数据并行架构的典型代表。早期的向量超级计算机也属于此类。
  • 适用性:非常适合规则的计算问题(如图像处理、数值模拟),但对于控制流复杂的任务可能不适用。

总结与展望

本节课中,我们一起学习了:

  1. 抽象与实现的区别:通过ISPC语言,我们看到了SPMD编程抽象如何通过底层的SIMD向量指令实现。
  2. 三种并行编程模型
    • 共享地址空间:通过共享变量通信,需要同步,硬件需支持缓存一致性。
    • 消息传递:通过发送/接收消息通信,易于构建大规模系统。
    • 数据并行:将函数映射到数据集合,适合规则计算,是GPU的编程基础。

在实践中,优秀的并行程序员需要掌握所有这些模型。现代计算系统通常是异构的:在芯片内多核间使用共享地址空间,在跨节点间使用消息传递,同时利用GPU进行数据并行计算。例如,十年前的世界第一超算“走鹃”就混合了Cell处理器(内部共享内存)和跨节点消息传递。

理解这些抽象及其实现,能帮助我们在不同的硬件平台上写出高效、正确的并行程序。在接下来的课程中,我们将深入探讨如何用这些模型编写具体的并行代码。

4:Lecture 4 - 1-24-18

概述

在本节课中,我们将开始学习如何编写实际的并行程序代码。我们将回顾之前讨论的并行编程模型,并通过简单的示例,了解如何用共享地址空间、消息传递和数据并行这三种不同风格编写功能性的并行程序。今天的目标是理解如何实现功能正确的并行代码,并初步探讨性能问题,后续课程将深入探讨如何优化并行软件的性能。

并行编程的步骤

上一节我们介绍了并行编程的基本概念,本节中我们来看看将一个程序转化为并行程序时需要考虑的几个关键步骤。

首先,我们需要将原始顺序程序中的整体工作分解成可以并行执行的任务。然后,将这些任务分配给不同数量的处理器。最后,确保所有线程能够正确地进行交互和协作。

以下是并行化过程的四个主要步骤:

  1. 分解:识别可以并行执行的任务。
  2. 分配:将任务分组并分配给处理器。
  3. 编排:实现线程间的通信与同步。
  4. 映射:将线程映射到物理硬件上。

示例应用

在深入探讨这些步骤之前,我们先介绍两个将作为运行示例的应用程序,它们能很好地展示我们想要并行化的代码类型。

示例一:海洋模拟

想象你是一位海洋学家,想要模拟海洋的物理特性。海洋是一个三维物体,但由于其深度远小于宽度,并且不同深度的压力和温度不同,通常被表示为一组在不同深度的二维平面。因此,我们可以将其视为一个3D矩阵,但每一层是单独计算的。

在软件中,我们将连续的二维空间离散化为网格点,每个数组元素代表海洋中特定坐标点的近似值。同时,我们还需要模拟时间变化,因此会按时间步长进行计算。

这个应用的核心计算部分,即在一个时间步内更新所有网格元素的偏微分方程,占据了绝大部分的计算时间。我们将重点关注这个部分。这个应用具有非常规则的数据结构,即一个密集的二维数组。

示例二:N体模拟

第二个示例是模拟宇宙中星体(如恒星)的物理行为。目标是模拟不同星体之间随时间变化的引力相互作用。

与海洋模拟的规则密集数组不同,星系中星体的位置分布非常不规则。某些区域星体密集,而其他区域则相对空旷。因此,我们使用稀疏数据结构(如图)来表示。此外,为了高效计算,我们使用一种称为四叉树(二维)或八叉树(三维)的数据结构来组织空间信息,以便快速找到附近的星体。

在这个模拟中,每个时间步都需要重建这棵树,因为星体在移动。计算步骤包括:构建树、遍历所有节点以计算其他星体的集体引力影响、更新每个星体的属性。

性能目标:加速比

回顾之前的内容,当我们讨论并行性能时,会使用加速比这个概念。其定义为在单个处理器上运行的时间与在多个处理器上运行的时间之比。

公式Speedup = T_sequential / T_parallel

我们的目标是最大化加速比,这意味着并行时间应尽可能小。

步骤一:分解

分解是将工作分解成任务的心理步骤。程序员需要思考程序中哪些部分是天然可以并行执行的工作块。

例如,在海洋模拟中,二维数组中的每个点都是一个潜在的任务。在N体模拟中,更新每颗星体就是一个潜在的任务。通常,任务数量会远多于处理器数量。

关于分解,需要注意任务间的依赖性。如果某个任务必须等待另一个任务完成后才能开始,那么这些任务就无法并行执行,构成了串行部分。

这里需要引入阿姆达尔定律。该定律指出,程序中必须串行执行的部分比例,最终会限制通过并行化所能获得的最大性能提升。

公式Speedup ≤ 1 / (S + (1 - S)/P),其中 S 是串行部分比例,P 是处理器数量。

考虑一个图像处理算法的例子:第一步是加倍每个像素的亮度(可完全并行),第二步是计算所有像素的平均值(存在依赖)。如果只并行化第一步,即使使用无限多的处理器,加速比上限也只有2倍。为了改进,可以让每个处理器先计算其分配区域的局部和,然后再串行汇总这些局部和,这样可以获得接近线性的加速比。

总之,分解步骤主要由程序员完成,编译器自动完成此步骤非常困难。

步骤二:分配

分配是将任务分组并分配给各个线程(处理器)的步骤。理想情况下,我们希望每个处理器获得等量的工作(负载均衡),同时最小化处理器间的通信开销。

然而,负载均衡和最小化通信这两个目标通常是矛盾的。例如,随机分配任务可以很好地实现负载均衡,但会导致极差的局部性和最大的通信开销。相反,将所有工作分配给一个处理器可以彻底消除通信,但完全无法实现并行。

分配可以是静态的或动态的。

  • 静态分配:在程序开始前就确定好如何分配工作。例如,在ISPC中,通过 programIndexprogramCount 进行交错分配。
  • 动态分配:在运行时动态决定工作分配。例如,使用工作队列,处理器空闲时从队列中获取新任务。动态分配能更好地平衡负载,但引入了管理队列的额外开销(如锁竞争)。

步骤三:编排

编排是引入软件原语,使线程能够按需进行通信和同步的步骤。这个步骤的具体实现高度依赖于所选的编程模型(共享地址空间、消息传递或数据并行)。

在这个步骤中,程序员需要关注:

  • 结构化通信:例如,在消息传递中,应尽量发送连续的、大块的数据,以分摊每次通信的开销。
  • 同步:确保在依赖数据准备好之前不开始计算。
  • 数据组织:设计数据结构以利于高效通信。

目标是在保证正确性的前提下,尽可能减少在同步和通信上浪费的时间。

步骤四:映射

映射是将逻辑线程映射到物理硬件处理器上的步骤。通常,程序员不太关心这一步,而由操作系统、编译器或硬件自动处理。

例如,Pthreads线程由操作系统调度;ISPC中的虚拟计算单元由编译器映射到向量通道;GPU上的线程则由硬件调度。

一个相关的考虑是,当需要在同一硬件上时分复用多个线程时,是放置相关的线程(利于通信局部性)还是不相关的线程(可能避免相同的性能瓶颈)。

并行编程实践:海洋模拟示例

现在,我们将通过一个简化的海洋模拟示例,来具体看看如何用不同编程模型实现并行化。

我们简化计算:为了更新网格中的一个元素,我们将其与上、下、左、右四个邻居的值取平均。我们有一个 N x N 的网格,并在顶部和底部添加额外的行,形成 (N+2) x N 的数组。

顺序代码的核心部分是一个双层循环,遍历网格,计算每个元素的新值(与邻居的平均值),并累加新旧值之差的绝对值以判断是否收敛。

识别依赖性与并行化策略

当我们尝试并行更新元素时,会发现数据依赖:更新一个元素需要其四个邻居的值。如果简单地并行化循环,会导致数据竞争和非确定性结果。

一个可行的策略是使用红黑排序。将网格想象成国际象棋棋盘,分为红格和黑格。在第一阶段,并行更新所有红格(此时只读取自身和黑格的值,而黑格未被修改)。然后,设置一个同步点(屏障),确保所有红格更新完成。在第二阶段,并行更新所有黑格(此时读取已更新的红格值)。这种方法避免了数据竞争,且无需复制整个数组,虽然结果与严格顺序执行略有不同,但通常是可接受的。

任务与分配

我们选择将矩阵的作为任务。由于行数 N 通常远大于处理器数 P,这提供了足够的任务量。接着,我们需要将行分组分配给处理器。

有两种分配方式:连续块分配(将连续的行块分给每个处理器)和交错分配(将行按处理器数循环分配)。为了最小化通信,连续块分配更优,因为每个处理器只需要与相邻处理器的边界行进行通信。而交错分配会导致几乎每一行都是边界,通信量大大增加。因此,我们选择连续块分配。

接下来,我们将分别用三种编程模型来实现这个并行程序。

数据并行实现

在数据并行模型中,语言和运行时系统为程序员承担了大量工作。代码改动非常小。

// 伪代码示意
forall (i = 1 to N) { // 并行化外层循环
    for (j = 1 to N) {
        newA[i][j] = (A[i][j] + A[i-1][j] + A[i+1][j] + A[i][j-1] + A[i][j+1]) / 5;
        diff += abs(newA[i][j] - A[i][j]);
        A[i][j] = newA[i][j];
    }
}
local_diff = reduce_add(diff); // 归约操作,计算局部和并全局汇总

主要变化是使用 forall 等结构指明外层循环可并行,并使用 reduce_add 等原语安全地执行全局累加(归约)操作。程序员还可以提示系统进行连续块分配。

共享地址空间实现

在共享地址空间模型中,程序员需要显式地管理线程、同步和共享数据的访问。代码变得更复杂。

// 伪代码示意
lock_t diff_lock;
barrier_t bar;
local_diff = 0;

// 每个线程执行以下代码
my_min = calculate_my_start_row(thread_id, N, P);
my_max = calculate_my_end_row(thread_id, N, P);

for (i = my_min to my_max) {
    for (j = 1 to N) {
        // 计算新值...
        local_diff += abs(new - old);
    }
}
barrier(bar); // 等待所有线程完成局部计算
lock(diff_lock);
global_diff += local_diff; // 安全地更新全局差
unlock(diff_lock);
barrier(bar); // 等待全局差更新完成
// 检查 global_diff 决定是否继续迭代

关键点包括:

  1. 使用保护对全局累加变量 global_diff 的更新,防止数据竞争。
  2. 使用屏障确保所有线程在进入下一阶段(如从更新红格切换到更新黑格)前都已完成当前阶段。
  3. 优化:每个线程先计算自己的 local_diff,最后再一次性更新到 global_diff,这大大减少了锁的竞争频率。
  4. 可以通过使用多个 diff 变量(如模3循环)来避免不必要的屏障,从而减少同步开销。

警告:不要试图使用普通的共享内存变量(如 int flag)进行细粒度的同步(如“等我写完你再读”),而不使用正确的同步原语(锁、屏障、原子操作或内存栅栏)。这会导致不可预测的错误,我们将在后续关于内存一致性模型的课程中详细讨论。

消息传递实现

在消息传递模型中,每个处理器只能访问自己的本地内存,通信必须通过显式的发送和接收消息来完成。代码风格与前两者差异很大。

// 伪代码示意(以进程为单位)
// 每个进程拥有自己的一部分行数据,并在顶部和底部分配额外的“幽灵行”
ghost_row_top[1...N];
ghost_row_bottom[1...N];

// 计算阶段前,交换边界数据
if (my_rank != 0) {
    send(&my_top_row, to: my_rank-1);
    recv(&ghost_row_top, from: my_rank-1);
}
if (my_rank != P-1) {
    send(&my_bottom_row, to: my_rank+1);
    recv(&ghost_row_bottom, from: my_rank+1);
}

// 现在可以使用本地数据和幽灵行进行计算
for (i = 1 to my_local_rows) {
    for (j = 1 to N) {
        // 计算新值,可能引用 ghost_row_top/bottom
        local_diff += abs(new - old);
    }
}

// 全局收敛判断:归约操作
if (my_rank == 0) {
    total_diff = local_diff;
    for (p = 1 to P-1) {
        recv(&temp_diff, from: p);
        total_diff += temp_diff;
    }
    // 广播结果
    for (p = 1 to P-1) {
        send(&total_diff, to: p);
    }
} else {
    send(&local_diff, to: 0);
    recv(&total_diff, from: 0);
}
// 根据 total_diff 决定是否继续

关键点包括:

  1. 幽灵单元格:在每个进程的本地数据边界分配额外空间,用于存放从邻居进程接收来的数据,使得核心计算循环的代码保持简洁。
  2. 显式通信:使用 sendrecv 在进程间传递整行的数据。
  3. 死锁风险:如果所有进程都先执行 send 操作,可能会因为无人执行 recv 而导致死锁。解决方法包括:让奇偶进程执行不同的顺序(如偶数进程先发后收,奇数进程先收后发),或者使用非阻塞通信原语。
  4. 全局操作:像判断收敛这样的全局归约操作,需要显式地通过消息传递来协调(通常指定一个主进程,如进程0,来收集和分发结果)。现代消息传递库(如MPI)通常提供内置的 reducebroadcast 等集体通信原语来简化这一过程。

总结

本节课中,我们一起学习了编写并行程序的高级步骤:分解、分配、编排和映射。我们通过两个示例应用(海洋模拟和N体模拟)理解了这些概念。然后,我们深入探讨了一个简化的海洋模拟问题,并对比了使用数据并行、共享地址空间和消息传递这三种主要编程模型实现并行化的不同方法。数据并行抽象程度最高,代码改动最小;共享地址空间需要显式管理线程和同步;消息传递则要求程序员完全掌控进程间的数据移动和通信。在接下来的两节课中,我们将更深入地探讨如何优化这些并行程序的性能。

5:任务调度与负载均衡 🧩

在本节课中,我们将学习如何将工作划分并调度到多个处理器上,以实现高效的并行计算。我们将重点探讨负载均衡的重要性,并比较静态与动态任务分配策略的优劣。


概述 📋

并行编程是一个迭代过程,通常需要多次尝试和性能测量才能达到最优。我们的核心目标有三个:

  1. 平衡负载:确保所有线程的工作量尽可能相等。
  2. 最小化通信:因为通信开销很大。
  3. 最小化软件开销:避免因复杂的调度逻辑引入过多额外指令。

首要建议是:从最简单的可行方案开始实现。这样能更快获得性能基准,并更容易理解后续的优化方向。


负载均衡的重要性 ⚖️

负载不均衡会导致部分处理器空闲,等待其他处理器完成任务,从而严重降低并行效率。即使负载接近平衡(例如,三个处理器工作量相同,第四个仅多20%),这“一点点”的不平衡也会造成性能损失,因为其他处理器在等待时无事可做。

因此,我们的主要议题就是探索不同的工作划分策略,以实现负载均衡。


静态任务分配 📐

静态分配的核心思想是:在程序运行前就确定好如何将工作划分给各个处理器

工作原理

我们之前在网格求解器的例子中见过这种方法,例如使用块划分或交错划分。代码中已经写死了分配逻辑,运行时只需按此逻辑执行。

优势与劣势

  • 优势:运行时开销极低,因为分配决策在运行前已完成。
  • 劣势:当任务执行时间不可预测或变化很大时,静态分配可能导致严重的负载不均。如果某个分区的任务意外地耗时更长,系统只能忍受这种不平衡。

适用场景

静态分配适用于任务执行时间可预测的情况:

  1. 任务完全相同:例如网格求解器中每个格点的计算。此时只需给每个处理器分配相同数量的任务即可。
  2. 任务不同但可预测:如果能通过某个输入参数(如数据规模)快速估算任务耗时,则可以在分配时进行“打包”,使每个处理器上的预计总耗时大致平衡。

虽然可能无法达到完美平衡,但只要因低运行时开销带来的收益大于轻微负载不均的损失,静态分配就是好选择。


半静态调度 🔄

这是一种介于静态和动态之间的实用技巧。

工作原理

在许多系统(如随时间步进仿真的物理模拟)中,虽然每个任务的工作量会变化,但变化相对缓慢。半静态调度的做法是:

  1. 定期(而非每次)对任务进行性能剖析(Profiling),获取估算其执行时间的参数(例如,需要计算的邻近星体数量)。
  2. 根据这次剖析结果,重新划分工作,并在接下来的一段时间内采用静态调度。
  3. 经过若干次迭代后,再次进行剖析和重新划分。

适用场景

适用于工作量非均匀但变化缓慢的场景,例如星系模拟(Barnes-Hut)、风洞中的飞机模型模拟等。


动态任务分配 🎯

当任务执行时间不可预测且差异很大时,静态分配效果不佳。此时需要动态分配。

基本原理

在程序运行时,线程根据需要动态地获取任务。一种简单的实现方式是使用一个共享的循环计数器:

// 简化的动态任务获取示例
lock();
int my_index = counter++;
unlock();
// 执行 tasks[my_index] 对应的任务

每个线程原子地获取一个迭代索引。如果某些迭代耗时更长,线程只会在完成当前工作后才去获取新工作,从而在整体上实现负载均衡。

任务粒度的重要性

然而,上述简单实现有一个潜在问题:如果每个任务(循环迭代)非常小,线程会频繁竞争锁以获取新任务,导致巨大的运行时开销

解决方案是调整任务粒度:每次不是获取一个任务,而是获取一批(例如5个或10个)任务。

  • 增大粒度:减少获取任务的频率,降低锁竞争开销。
  • 减小粒度:提高负载均衡的灵活性。

目标是找到开销与负载均衡之间的最佳平衡点。通常,任务粒度应设置得足够大以降低开销,但又足够小以避免负载不均。

任务排序的智慧

即使采用动态调度,如果任务执行时间差异很大且顺序不合理,仍可能遇到问题。例如,如果将大量小任务先放入队列,最后一个才是大任务,那么当其他线程快速完成小任务后,将不得不等待最后一个线程处理那个大任务。

优化策略:理想情况下,应先调度大任务,后调度小任务。这类似于先用大石块填充,再用沙粒填补缝隙。实现上可以:

  1. 若能预估任务大小,则优先将大任务放入队列。
  2. 动态调整任务粒度,开始时用大粒度,接近结束时改用小粒度。

分布式工作队列 🗂️

使用一个中心化的工作队列,所有线程都从中获取任务,可能造成严重的锁竞争。分布式工作队列是更好的选择。

工作原理

  1. 每个处理器(或硬件线程)拥有自己的本地工作队列。
  2. 初始时,根据某种启发式方法将任务分配到各个队列。
  3. 每个线程优先从自己的本地队列获取任务,这提供了良好的局部性,且无竞争。
  4. 当某个线程的本地队列为空时,它不会空闲,而是从其他线程的队列中窃取任务

工作窃取的细节

  • 窃取来源:通常随机选择其他队列进行窃取,以避免系统性不平衡。
  • 窃取量:不会只窃取一个任务(否则很快又需要窃取),也不会窃取全部(导致被窃取线程饥饿)。通常窃取队列中一部分任务(例如一半)。
  • 终止检测:需要一种机制来判断所有队列是否都已为空,且没有新任务生成,此时程序才能结束。

分布式工作队列结合了动态调度的负载均衡优势和本地访问的局部性优势,是一种非常强大的机制。

处理任务依赖

理想情况下,任务应相互独立。但如果存在依赖关系,可以在任务数据结构中加入依赖信息。线程只会在某个任务的所有前置条件都满足时,才将其从队列中取出执行。这增加了开销,但扩展了动态调度的应用范围。


分治并行性:以Cilk Plus为例 🌳

之前讨论主要针对数据并行(如循环)。另一种常见模式是分治并行性,通常通过递归实现,例如快速排序。

Cilk Plus 语言简介

Cilk Plus 是一种用于表达分治并行性的语言扩展,其核心原语是:

  • cilk_spawn:表示其后的函数调用可以与当前函数的剩余部分(称为延续)并发执行。
  • cilk_sync:等待本函数内所有通过 spawn 创建的任务完成。每个函数末尾都有一个隐式的 sync

spawn 只是提示运行时系统可能存在并行机会,并不强制创建新线程。

运行时系统实现

Cilk Plus 不会为每个 spawn 都创建昂贵的操作系统线程。相反:

  1. 启动时,为每个硬件线程创建一个工作线程。
  2. 工作线程从一个任务队列中获取工作,这个队列管理着由 spawn 暴露的并行任务。

延续窃取策略

当线程遇到 cilk_spawn foo(); bar(); 时,有两个可并行部分:foo()(子任务)和 bar()(延续)。线程必须立即执行其中一个,并将另一个的描述放入自己的任务队列,供其他线程窃取。这里有两种选择:

  1. 延续优先:线程执行 bar(),将 foo() 入队。这类似于广度优先遍历,会快速生成大量任务放入队列。
  2. 子任务优先:线程执行 foo(),将延续(即执行 bar() 的上下文)入队。这类似于深度优先遍历,更接近顺序执行顺序,且队列大小有界。

Cilk Plus 选择了子任务优先策略,因为它能更好地控制队列大小,并且与分治算法的特性吻合。

工作窃取与队列操作

在分治算法中,先放入队列的任务通常对应更大的工作子集(因为递归早期划分的)。Cilk Plus 的每个本地队列是一个双端队列:

  • 本地线程:从队列的底部推入和弹出任务(后进先出,LIFO)。
  • 窃取线程:从队列的顶部窃取任务(先进先出,FIFO)。

这种策略(窃取最旧的任务)倾向于让窃取者拿到更大的任务块,有利于负载均衡和保持空间局部性。

同步的实现

sync 点,需要协调所有并发任务。Cilk Plus 采用贪婪(greedy)同步策略

  • 不要求必须由发起 spawn 的原始线程来最终完成同步。
  • 一个共享数据结构跟踪未完成的任务数。
  • 最后一个完成任务的线程负责在完成后立即继续执行 sync 之后的代码。这避免了原始线程可能空闲等待的情况,减少了同步开销。


总结 🎓

本节课我们一起深入探讨了并行计算中任务调度与负载均衡的核心技术。

我们首先比较了静态分配动态分配。静态分配开销极低,但要求任务执行时间可预测;动态分配能适应不规则负载,但需注意管理其开销。半静态调度是两者间实用的折中方案。

在动态调度中,我们认识到任务粒度是一个关键可调参数,需要在开销与负载均衡间取得平衡。分布式工作队列与工作窃取是实现高效动态调度的有效架构,能同时兼顾局部性和负载均衡。

最后,我们以 Cilk Plus 为例,研究了分治并行性的调度。了解了其“子任务优先”的延续窃取策略、双端队列的窃取方式(从顶部窃取大任务)以及贪婪同步策略,这些设计共同确保了分治算法的高效并行执行。

掌握这些任务调度策略,是编写高性能并行程序的基础。在后续的编程实践中,请务必遵循“从简开始,测量优化”的准则,灵活运用这些策略来解决负载均衡的挑战。

6:Lecture 6 - 1-29-18

概述

在本节课中,我们将学习如何获取系统硬件信息,并以此为基础,通过一系列代码优化技术来提升程序性能。我们将从了解 /proc 虚拟文件系统开始,逐步深入到具体的性能分析和优化策略。


系统信息获取:/proc 文件系统

在现代操作系统中,/proc 是一个虚拟文件系统。它并非真实的文件系统,但呈现为文件系统的形式。内核通过 /proc 导出大量系统状态信息,这种方式便于我们以文件操作的方式访问数据,避免了每次查询都需进行系统调用的开销。

/proc 目录下,以数字命名的目录代表正在运行的进程,其数字即为进程ID。进入某个进程目录(例如 /proc/37684),可以看到多个文件,这些文件包含了该进程的各种信息,如文件描述符、内存映射等。

通过标准的 Unix 文件权限,系统确保只有进程所有者才能查看该进程的私有信息。系统管理员(root用户)甚至可以修改其中一些可调参数来改变系统行为。

对于性能分析,一个关键文件是 /proc/cpuinfo。该文件提供了关于 CPU 的详细信息。其格式易于阅读和解析,每行包含一个标签、一个冒号和对应的信息。

代码示例:查看CPU信息

cat /proc/cpuinfo

/proc/cpuinfo 的输出中,有几个关键字段值得注意:

  • model name:处理器型号。
  • cpu MHz:处理器当前运行速度。由于现代处理器支持动态频率调整,这个值可能随时变化。
  • siblingscpu coressiblings 数量大于 cpu cores 数量通常意味着启用了超线程技术。操作系统将每个超线程核心视为一个独立的逻辑处理器进行调度。
  • cache size:通常显示所有核心共享的最后一级缓存(如L3缓存)的大小。
  • flags:列出了处理器支持的功能集,例如我们使用的 AVX2 指令集。

了解处理器的具体型号(微架构代号,如 Broadwell)至关重要,因为这样你可以查阅技术文档,获知处理器的功能单元数量、指令延迟和缓存结构等详细信息,从而有针对性地优化代码。


性能分析与优化基础

上一节我们介绍了如何获取硬件信息,本节中我们来看看如何基于这些信息进行性能分析和优化。优化代码时,我们首先需要找到程序的性能瓶颈。

阿姆达尔定律指出,对程序中某一部分进行优化所能获得的整体性能提升,受限于该部分所占的执行时间比例。因此,我们应该优先优化那些最耗时的部分,通常是程序中最内层的工作循环。

优化的第一步永远是:先保证正确性,再进行优化。编写出正确、清晰的代码,然后对其进行基准测试,获取精确的性能数据,以证明你即将优化的部分确实是性能瓶颈。人类的直觉通常是系统性能的糟糕指标,必须进行测量。

一个反面案例:曾经有一个项目,开发者花费数月优化计算密集型代码,仅获得2%的性能提升。后来通过系统调用追踪工具 strace 发现,问题根源在于代码中一个无意的内存泄漏导致 malloc 被调用了上万次。如果事先进行性能剖析,这个问题可能在几分钟内就被发现并修复。


代码优化实战:循环与计算

现在,让我们将理论应用于实践,分析一段具体的代码并进行优化。我们关注代码中最内层的循环,因为这里是程序花费时间最多的地方。

初始代码包含嵌套循环,内层循环进行了大量的乘法和除法运算,这些是相对昂贵的操作。我们的优化思路是减少这些操作的数量。

以下是初步的优化步骤:

  1. 提取循环不变计算:将内层循环中不随迭代变化的计算移到外层循环。
  2. 消除除法:将除法转换为乘法(例如,乘以一个预先计算好的倒数)。

这些简单的重构能带来立竿见影的性能提升,因为它们几乎不需要代价就减少了内层循环的工作量。

为了更深入地优化,我们需要查看编译器生成的汇编代码。通过分析汇编,我们可以了解指令的实际执行顺序和潜在的瓶颈。例如,我们可能发现乘法指令的延迟(完成一次运算所需的时钟周期数)和发射时间(可以连续发射新指令的最小间隔周期数)限制了性能。


高级优化技术:循环展开

上一节我们通过查看汇编代码识别了瓶颈,本节中我们来看看一种经典的高级优化技术:循环展开。

循环对性能有负面影响:每次迭代都需要进行条件测试,即使这个测试在绝大多数情况下结果都相同;此外,循环中的分支会干扰处理器的流水线和预测机制,减少指令级并行的机会。

循环展开通过手动增加循环体每次迭代的工作量来减少迭代次数。例如,将循环展开4倍,意味着每次迭代处理4个数据元素,循环测试和分支跳转的次数减少为原来的1/4。这能降低分支开销,并为处理器提供更长的、无分支的指令序列,有利于流水线填充和乱序执行。

然而,循环展开也有代价:

  • 代码体积增大:可能对指令缓存不友好。
  • 寄存器压力:可能需要更多寄存器来保存临时变量,可能导致寄存器溢出到内存。

因此,需要根据目标平台的特点(如寄存器数量、缓存大小)来选择合适的展开因子。通过实验测量不同展开因子下的性能,可以找到最佳点。

在应用循环展开并结合之前的优化(如利用浮点运算的结合律和分配律进行重构)后,我们获得了显著的性能提升。


向量化:利用现代指令集

经过一系列传统的“手动”优化,我们获得了可观的加速。现在,让我们尝试一种更现代的优化手段:向量化。

向量化允许单条指令同时处理多个数据元素(例如,使用AVX2指令集一次处理8个单精度浮点数)。我们使用ISPC语言可以相对轻松地实现向量化。

在向量化代码中,我们注意到两个变量被声明为 uniformuniform 变量在所有并行执行的程序实例中具有相同的值。这意味着对 uniform 变量的计算只需进行一次,然后结果被所有实例共享,而不是在每个实例中重复计算。这进一步减少了冗余工作。

向量化带来了巨大的性能飞跃,其加速比甚至超过了简单的数据并行倍数(如8倍),部分原因就在于 uniform 变量优化消除了额外的计算。

让我们来总结一下优化过程:

  • 传统的、基于算法和循环的优化(提取不变计算、消除除法、循环展开等)带来了约15倍的加速。
  • 向量化优化(利用SIMD指令)在此基础上又带来了约5.4倍的加速。
  • 两者结合,总加速比达到了惊人的82倍。

这个案例的启示是:虽然本课程重点讨论并行架构和高级优化技术,但扎实的、来自像15-213这样的基础课程的传统优化技能仍然至关重要,它们往往能带来巨大的收益。在实际开发中,合理的策略是:先进行力所能及的基本优化以保证代码清晰,然后尝试向量化这类“高性价比”的优化,最后根据性能目标决定是否需要投入更多精力进行深度优化。对于软件中非核心的热点代码,可读性和可维护性通常比极致的性能更重要;但对于真正的性能关键内核,优化则是必不可少的。


总结

本节课我们一起学习了如何通过 /proc 文件系统获取详细的硬件信息,并回顾了性能优化的核心原则。通过一个具体的代码优化案例,我们实践了从性能剖析、传统优化(循环展开、计算重构)到现代优化(向量化)的全过程。结果表明,结合扎实的传统优化技巧与现代并行硬件特性,能够释放出巨大的性能潜力。请记住,优化始于测量,并始终在代码清晰度、可维护性与性能需求之间做出权衡。

7:GPU架构与CUDA编程入门 🚀

在本节课中,我们将要学习图形处理器(GPU)的基本概念、其发展背景,以及如何使用CUDA进行数据并行编程。GPU是现代高性能计算中极为重要的组成部分,它通过大规模并行处理能力,极大地扩展了计算的可能性。我们将从图形渲染的需求出发,理解GPU的计算模型,并学习如何编写高效的CUDA程序。

GPU的起源与图形渲染需求 🎮

上一节我们介绍了并行计算的基本概念,本节中我们来看看GPU是如何从图形处理领域发展而来的。GPU最初是作为图形处理单元出现的,其设计初衷是为了高效处理计算机图形渲染任务。

现代计算机图形学涉及对大量小型对象(如三角形面片)进行相同或类似的操作。例如,为了渲染一个三维物体,通常会将其表面分解为许多三角形面片,然后对每个面片进行着色、光照计算等操作,最后将其投影到二维屏幕上。这种操作模式天然具有数据并行性,因为每个三角形或像素的处理可以独立进行。

随着对图形真实感要求的不断提高,例如添加复杂的光照效果、反射、折射、纹理映射等,计算需求呈指数级增长。这推动了硬件设计向能够同时处理大量简单计算单元的方向发展。

GPU硬件架构概览 🏗️

了解了GPU的应用背景后,我们来看看其硬件架构的基本形态。一块现代GPU芯片上集成了大量被称为“流多处理器”(SM,在NVIDIA术语中)的处理单元。

每个SM类似于一个简化的多线程CPU核心,但设计重点在于吞吐量而非单线程性能。一个GPU包含多个这样的SM,它们共享一个高带宽的片上内存(全局内存)。例如,课程中提到的GPU拥有20个SM。其核心思想是:用大量简单的计算单元替代复杂的控制逻辑,从而在芯片面积和功耗限制下提供极高的浮点运算能力。

图形渲染管线与可编程着色器 ⚙️

GPU的编程模型深深植根于图形渲染管线。渲染管线是一系列将三维物体转换为二维屏幕像素的固定处理阶段。

以下是图形渲染管线的主要阶段:

  1. 顶点处理:处理三维物体的顶点坐标。
  2. 图元装配:将顶点连接成三角形等基本图形。
  3. 光栅化:将三角形转换为屏幕上的像素片段。
  4. 片段处理(着色):计算每个像素的最终颜色。
  5. 输出合并:处理深度测试、混合等,生成最终像素。

关键的发展是“可编程着色器”的引入。着色器是一小段运行在GPU上的程序,用于替代管线中某些固定功能阶段(如顶点着色器、片段着色器)。这使得开发者可以自定义光照、材质等效果。着色器代码看起来类似C语言,在一个数据并行的上下文中执行,即同一段代码被并行应用于无数个顶点或像素。

CUDA编程模型介绍 💻

当人们意识到GPU的并行能力不仅限于图形后,通用GPU计算(GPGPU)便应运而生。CUDA是NVIDIA推出的并行计算平台和编程模型,它允许开发者使用类似C的语言来利用GPU进行通用计算。

CUDA程序由主机(Host)代码和设备(Device)代码组成。主机代码运行在CPU上,负责管理设备内存、启动内核等;设备代码(即内核)运行在GPU上,执行并行计算任务。

CUDA的核心抽象是线程层次结构

  • 线程(Thread):最基本的执行单元,处理一个数据元素。
  • 线程块(Block):一组线程的集合,块内的线程可以协作(通过共享内存和同步)。一个块内的线程数量有限制(例如1024)。
  • 网格(Grid):所有线程块的集合,用于完成整个计算任务。

内核启动的语法形式如下:

kernelFunction<<<gridDim, blockDim>>>(arguments);

其中gridDimblockDim定义了网格和线程块的维度。

CUDA内核与内存模型示例 📝

让我们通过一个具体的例子来理解CUDA编程。假设我们需要将两个矩阵相加:C = A + B

首先,我们编写一个简单的内核函数,每个线程负责计算一个输出元素:

__global__ void matrixAdd(float* A, float* B, float* C, int width, int height) {
    int col = blockIdx.x * blockDim.x + threadIdx.x; // 计算列索引
    int row = blockIdx.y * blockDim.y + threadIdx.y; // 计算行索引
    int idx = row * width + col; // 转换为线性内存索引

    if (row < height && col < width) { // 边界检查
        C[idx] = A[idx] + B[idx];
    }
}

在主函数中,我们需要分配设备内存、复制数据、启动内核并取回结果:

// 主机代码示例片段
float *d_A, *d_B, *d_C; // 设备指针
cudaMalloc(&d_A, size); // 在设备上分配内存
cudaMemcpy(d_A, h_A, size, cudaMemcpyHostToDevice); // 复制数据到设备

// 定义执行配置
dim3 blockDim(16, 16); // 每个块有16x16个线程
dim3 gridDim((width + blockDim.x - 1) / blockDim.x,
             (height + blockDim.y - 1) / blockDim.y); // 计算所需的网格大小

matrixAdd<<<gridDim, blockDim>>>(d_A, d_B, d_C, width, height); // 启动内核

cudaMemcpy(h_C, d_C, size, cudaMemcpyDeviceToHost); // 将结果复制回主机
cudaFree(d_A); // 释放设备内存

CUDA采用分离的地址空间模型。CPU(主机)和GPU(设备)拥有各自独立的内存,必须通过cudaMemcpy函数进行显式数据拷贝。

性能优化:利用共享内存 🚀

直接访问全局内存速度较慢。为了优化性能,CUDA提供了共享内存,这是一种由程序员显式管理的、块内线程共享的高速缓存。

以下是一个使用共享内存优化一维卷积(均值滤波)的示例:

__global__ void convolution1D(float* input, float* output, int n) {
    extern __shared__ float s_data[]; // 动态声明共享内存
    int tid = threadIdx.x;
    int gid = blockIdx.x * blockDim.x + threadIdx.x;

    // 每个线程从全局内存加载一个元素到共享内存
    s_data[tid] = (gid < n) ? input[gid] : 0.0f;

    __syncthreads(); // 确保块内所有线程都已完成数据加载

    // 进行卷积计算(现在从共享内存读取数据,速度更快)
    if (tid > 0 && tid < blockDim.x - 1 && gid < n - 2) {
        output[gid] = (s_data[tid-1] + s_data[tid] + s_data[tid+1]) / 3.0f;
    }
}

__syncthreads()是一个屏障同步原语,确保块内所有线程都执行到此点后,才继续向下执行,这对于共享内存的正确使用至关重要。

GPU执行模型:从线程到Warp ⚡

CUDA的编程模型如何映射到实际的GPU硬件上呢?关键在于Warp的概念。

  • Warp:GPU调度和执行的基本单位。通常一个Warp包含32个连续的线程。
  • SIMD执行:一个Warp中的32个线程在同一周期内执行相同的指令(单指令多数据,SIMD)。如果线程间存在分支(如if-else),会导致Warp分化,不同路径的线程会被串行执行,降低效率。
  • 硬件多线程:每个SM可以同时管理多个活跃的Warp(例如64个)。当一个Warp因等待内存访问而停滞时,硬件调度器会立刻切换到另一个就绪的Warp执行,从而隐藏内存延迟,最大化硬件利用率。

线程块被分配到SM上执行。一个SM可以同时执行多个线程块,具体数量受限于SM的共享内存、寄存器等资源。网格中的线程块可以以任何顺序在任意SM上执行,这由硬件调度器动态管理。

CUDA同步与原子操作 🔒

在CUDA中,同步有不同的层次:

  1. 块内同步:使用__syncthreads()
  2. 内核级同步:内核启动是隐式的同步点。主机在启动下一个内核或拷贝数据前,必须等待当前内核的所有线程块执行完毕。
  3. 全局内存原子操作:对于跨线程的归约操作或计数器更新,需要使用原子操作(如atomicAdd)来保证结果的正确性。原子操作能确保该内存操作不可分割地完成,但性能开销较高。

总结 📚

本节课中我们一起学习了GPU并行计算的基础知识。我们从GPU因图形渲染需求而诞生的历史讲起,理解了其数据并行的计算模型。我们深入探讨了CUDA编程模型,包括其主机-设备架构、线程层次结构(线程、块、网格)以及分离的内存空间。

我们通过矩阵加法和卷积优化的例子,学习了如何编写CUDA内核,并了解了使用共享内存和同步进行性能优化的关键技术。最后,我们揭示了CUDA抽象模型之下的硬件执行原理,特别是Warp的SIMD执行和硬件多线程机制,这些是理解GPU高性能的关键。

GPU编程的核心在于将计算任务重构为大量可独立或协作处理的细粒度并行工作项,并通过合理的组织来充分利用其庞大的计算资源和极高的内存带宽。

8:CUDA编程入门与矩阵乘法优化 🚀

在本节课中,我们将学习CUDA编程的基础知识,并通过一个矩阵乘法的实例,探讨如何从简单的串行实现逐步优化到高效的GPU并行实现。我们将重点关注CUDA的架构模型、内存层次结构以及性能优化的核心策略。


概述 📋

上一讲中,Randy介绍了CUDA的基本架构和工作原理。本节我们将进一步深入,回顾CUDA编程的核心概念,并通过一个具体的矩阵乘法案例,演示如何将CPU代码移植到GPU上,并利用CUDA的并行特性进行性能优化。我们将从最基础的实现开始,逐步引入分块、内存访问优化等技术。


CUDA核心概念回顾 🧠

在深入优化之前,我们先快速回顾一下CUDA编程的几个核心概念。

  • CPU与GPU:CPU是传统计算机的“大脑”,而GPU(图形处理单元)最初是显卡的核心,现在也用于通用计算。
  • 主机与设备:在CUDA编程中,“主机”指计算机本身(CPU和内存),“设备”指GPU板卡。两者拥有独立的内存空间。
  • CUDA架构:CUDA是NVIDIA推出的软件框架,用于在GPU上进行通用目的计算。其开源版本称为OpenCL。
  • 全局内存与共享内存
    • 全局内存 是设备(GPU)上的内存,对所有线程块可见。我们使用 cudaMalloccudaFreecudaMemcpy 在主机和设备内存之间传输数据。
    • 共享内存 是与单个线程块关联的内存,由该块内的所有线程共享。
  • 内核、线程与线程块
    • 内核 是我们希望在GPU所有核心上并行执行的代码段。
    • 线程 是与内核一个实例相关联的工作的抽象。一个内核会启动海量线程。
    • 线程块 是线程的一个分区,这些线程将被调度到一个流式多处理器(SM)上执行。
  • 网格、SM、核心与线程束
    • 所有线程块的集合构成 网格
    • SM 是GPU上的处理器,类似于计算机中的CPU,但其内部包含多个核心。
    • 核心 是SM内部的单个处理器。
    • 线程束 是SM内部对线程块的进一步划分,用于将工作分配给核心。一个线程束包含32个线程,它们以锁步方式执行。
  • 关键限定符与函数
    • __shared__:声明位于每个线程块共享内存中的变量。
    • __global__:放在函数前,使其成为可在设备上执行、从主机调用的内核函数。
    • cudaMalloc, cudaFree, cudaMemcpy:用于管理设备内存。
  • 同步__syncthreads() 用于同步一个线程块内的所有线程,确保一个工作阶段完成后才进入下一阶段。
  • 内置变量
    • threadIdx, blockIdx:分别表示线程在线程块内的索引和线程块在网格内的索引。它们是三维的(.x, .y, .z)。
    • blockDim, gridDim:分别表示线程块的维度(各维度的线程数)和网格的维度(各维度的块数)。


实用编程技巧与调试建议 🔧

在开始编写CUDA代码前,了解一些最佳实践和调试技巧至关重要。

错误检查

在213课程中,我们强调检查所有库函数调用的返回值。在CUDA编程中同样如此。使用 cudaError_t 类型和 cudaGetErrorString 函数来包装CUDA调用,可以快速定位问题。

#define gpuErrchk(ans) { gpuAssert((ans), __FILE__, __LINE__); }
inline void gpuAssert(cudaError_t code, const char *file, int line) {
    if (code != cudaSuccess) {
        fprintf(stderr, "GPUassert: %s %s %d\n", cudaGetErrorString(code), file, line);
        exit(code);
    }
}
// 用法
gpuErrchk( cudaMalloc(&d_a, N * sizeof(int)) );

对于内核启动,不能直接包装调用,但可以在启动后检查最后一个错误:

myKernel<<<blocks, threads>>>(args);
gpuErrchk( cudaPeekAtLastError() );
gpuErrchk( cudaDeviceSynchronize() ); // 等待内核完成

使用调试工具

GDB也支持CUDA调试。你可以使用熟悉的GDB命令在不同CUDA线程间切换,info locals 可以显示CUDA线程的局部变量信息。

避免硬编码常量

不要将硬件相关的数值(如线程块大小)硬编码在代码中。这些“常量”可能会随着硬件升级而改变。尽可能通过计算或查询设备属性来获取这些值。

向上取整的通用方法

在划分工作时,经常需要将总任务数向上取整到线程块大小的倍数。不要在每个地方重复编写取整逻辑,应将其封装为一个内联函数或宏。

inline int divUp(int total, int grain) {
    return (total + grain - 1) / grain;
}
// 计算需要的线程块数量
int numBlocks = divUp(N, threadsPerBlock);

确保正确性优先

在并行化任何代码之前,必须有一个已知正确的串行版本作为基准。并行化是为了获得更快的正确结果,而不是为了获得一个错误的答案。始终用基准答案来验证并行版本的正确性。

谨慎使用 printf

在CUDA内核中使用 printf 需要谨慎。输出存储在固定大小的循环缓冲区中,缓冲区满后会覆盖旧数据。缓冲区会在特定事件时刷新(如内核启动、同步函数调用、上下文销毁时),但不是在程序退出时。因此,过度使用 printf 可能导致输出丢失或混乱。建议仅在调试时少量使用,并在内核结束后调用 cudaDeviceSynchronize() 来确保输出刷新。

边界检查

在C语言中,数组越界访问可能导致段错误,这至少能让你知道程序出错了。但在CUDA中,越界访问通常只会导致错误的结果,这更难调试。因此,应在代码中添加大量的边界检查,尤其是在访问数组时。这些检查代码可以包装在宏中,以便在发布版本中轻松关闭。

#ifdef DEBUG
#define CHECK_BOUNDS(i, n) if ((i) >= (n)) return;
#else
#define CHECK_BOUNDS(i, n)
#endif

![](https://github.com/OpenDocCN/dsai-notes-pt2-zh/raw/master/docs/cmu-15418-prll-comp/img/2de2af2b7f3d8572c17b65863b3856f8_17.png)

__global__ void myKernel(int *arr, int n) {
    int idx = threadIdx.x + blockIdx.x * blockDim.x;
    CHECK_BOUNDS(idx, n) // 边界检查
    arr[idx] = ...;
}


案例研究:矩阵乘法的CPU优化 🧮

上一节我们回顾了CUDA基础和编程技巧,本节我们来看看如何将这些知识应用于一个经典问题:矩阵乘法。我们将从CPU优化开始,为后续的GPU移植打下基础。

矩阵乘法 C = A * B(其中A是MxK,B是KxN)涉及大量计算,复杂度为 O(MNK)。其内存访问模式对性能影响巨大。

基础实现及其问题

最基础的实现使用三层嵌套循环。在按行主序存储的系统中,对矩阵A的访问是连续的(行优先),但对矩阵B的访问是跳跃的(列优先)。当矩阵规模增大,无法完全放入CPU缓存时,对B的非连续访问会导致大量的缓存缺失,性能急剧下降。

优化策略:预转置

一个简单的优化策略是预先将矩阵B转置。这样,在计算过程中,对转置后的B'的访问也变成了行优先,从而改善了内存访问的局部性。虽然转置本身需要 O(K*N) 的额外操作,但这个成本可以被后续大量计算分摊,总体性能得到显著提升。

性能对比:在图表中,基础实现的性能在矩阵尺寸达到128-512左右时因缓存容量不足而骤降。而使用预转置后,性能曲线变得更加平稳,避免了剧烈的性能下跌。


将矩阵乘法移植到GPU 🚀

上一节我们在CPU上通过预转置优化了矩阵乘法。现在,我们将其移植到GPU上,利用CUDA进行大规模并行计算。

SPMD模型与CUDA执行流程

CUDA遵循单程序多数据(SPMD)模型。大量线程(内核实例)在GPU的多个处理器上执行相同的代码。它们共享全局内存,并通过屏障(如 __syncthreads())进行同步,而不是传统的互斥锁、信号量等原语。

基本的CUDA程序流程如下:

  1. 主机(CPU)设置数据,并将其复制到设备(GPU)的全局内存中。
  2. 主机启动内核,设备上的大量线程并行处理数据。
  3. 内核执行完毕后,主机通过同步等待所有线程完成。
  4. 主机将结果从设备内存复制回主机内存。

性能瓶颈通常在于主机与设备之间的数据传输速度。

基础GPU实现

首先,我们进行一个直接的移植:每个GPU线程负责计算结果矩阵C中的一个元素。线程的索引 (i, j) 通过 blockIdxblockDimthreadIdx 计算得出。

__global__ void matrixMulKernel(float* C, float* A, float* B, int N) {
    int i = blockIdx.y * blockDim.y + threadIdx.y; // 行
    int j = blockIdx.x * blockDim.x + threadIdx.x; // 列
    if (i >= N || j >= N) return; // 边界检查

    float sum = 0.0f;
    for (int k = 0; k < N; ++k) {
        sum += A[i * N + k] * B[k * N + j]; // 注意B的访问
    }
    C[i * N + j] = sum;
}
// 主机端启动配置
dim3 threadsPerBlock(16, 16);
dim3 numBlocks(divUp(N, threadsPerBlock.x), divUp(N, threadsPerBlock.y));
matrixMulKernel<<<numBlocks, threadsPerBlock>>>(d_C, d_A, d_B, N);

这个简单的移植带来了巨大的性能提升,远超任何CPU优化版本。然而,我们注意到,在内核中访问矩阵B时,B[k * N + j] 是列优先的,这可能在GPU上造成问题。

内存访问模式的重要性:合并访问

在GPU上,没有传统的多级缓存,但内存访问模式同样关键,原因在于“合并访问”。当一个线程束(32个线程)访问连续的内存地址时,这些访问可以合并为一次或少数几次内存事务,效率极高。如果线程束中的线程访问分散的内存地址,则需要“收集”或“分散”操作,导致多次内存事务,性能下降。

在我们的内核中,如果线程束中的线程具有连续的 threadIdx.x(即连续的列索引 j),它们对B的访问 B[k * N + j] 就是分散的(间隔N),无法合并。这就是为什么简单的GPU实现仍有优化空间。

性能对比:图表显示,将内核中循环的 ij 角色互换(改变内存访问顺序),会导致性能差异,这正是由于合并访问是否有效造成的。


高级优化:分块矩阵乘法 🧱

上一节我们看到,即使简单的GPU移植也能带来显著加速,但内存访问模式限制了其最终性能。本节我们将应用在CPU优化中学到的分块技术,进一步提升GPU性能。

分块策略

与CPU缓存优化类似,我们将大矩阵划分为小块(Tile)。每个GPU线程块负责计算结果矩阵C中的一个块。这样,在计算这个块时,线程块只需要重复加载A和B中对应的几个小块到共享内存中,极大地减少了访问全局内存的次数,并促进了线程块内线程对共享内存的高效、合并访问。

分块内核实现

以下是分块矩阵乘法内核的简化框架:

__global__ void matrixMulTiledKernel(float* C, float* A, float* B, int N) {
    // 声明线程块内的共享内存,用于存储A和B的一个块
    __shared__ float sA[TILE_WIDTH][TILE_WIDTH];
    __shared__ float sB[TILE_WIDTH][TILE_WIDTH];

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

    // 计算当前线程负责的C中的元素坐标
    int row = by * TILE_WIDTH + ty;
    int col = bx * TILE_WIDTH + tx;

    float sum = 0.0f;
    // 循环遍历所有需要的块
    for (int ph = 0; ph < ceil(N/(float)TILE_WIDTH); ++ph) {
        // 协作地将A和B的当前块加载到共享内存
        if (row < N && (ph*TILE_WIDTH + tx) < N)
            sA[ty][tx] = A[row * N + (ph*TILE_WIDTH + tx)];
        else
            sA[ty][tx] = 0.0f;

        if ((ph*TILE_WIDTH + ty) < N && col < N)
            sB[ty][tx] = B[(ph*TILE_WIDTH + ty) * N + col];
        else
            sB[ty][tx] = 0.0f;

        __syncthreads(); // 等待块内所有线程完成数据加载

        // 使用共享内存中的数据计算部分和
        for (int k = 0; k < TILE_WIDTH; ++k) {
            sum += sA[ty][k] * sB[k][tx];
        }
        __syncthreads(); // 等待所有线程计算完毕,再加载下一个块
    }

    // 将最终结果写回全局内存
    if (row < N && col < N) {
        C[row * N + col] = sum;
    }
}

性能飞跃与陷阱

结合了预转置(确保行优先访问)和分块技术后,GPU矩阵乘法的性能实现了又一次巨大飞跃。优化后的性能曲线远高于基础GPU实现。

然而,分块代码需要特别注意线程同步和边界条件。例如,在加载数据到共享内存的 if 判断中,如果某些线程因边界条件而提前 returncontinue,它们可能永远无法到达 __syncthreads() 屏障,导致整个线程块死锁。因此,所有线程必须经过相同的同步点。

进一步优化:使用向量化加载

更极致的优化包括使用向量化类型(如 float4)一次加载4个元素。这能更好地利用内存带宽,因为GPU的内存控制器更擅长处理大块的数据传输。


总结与核心建议 🎯

本节课我们一起学习了CUDA编程从基础到进阶的完整流程,并通过矩阵乘法案例实践了优化策略。

核心收获

  1. 理解层次结构:掌握网格、线程块、线程束、SM、核心的层次关系是有效编程的基础。
  2. 利用快速内存:合理使用共享内存来减少对全局内存的慢速访问。
  3. 优化内存访问:确保线程束内的内存访问是连续的,以实现合并访问,避免昂贵的收集/分散操作。
  4. 轻量级同步:使用 __syncthreads() 进行块内同步,但要确保所有线程都能到达同步点。

通用优化流程建议

  1. 先求正确:首先实现一个清晰、易于理解和调试的串行版本作为基准。
  2. 建立基准:测量串行版本的性能,了解起点。
  3. 逐步并行化与优化:从简单的GPU移植开始,然后逐步引入分块、共享内存等优化技术。
  4. 持续验证:每一步优化后,都要与基准结果进行对比,确保正确性。
  5. 关注内存:在GPU上,内存访问通常是主要瓶颈。计算很快,但低效的内存访问会严重拖累性能。优先优化内存访问模式(合并访问、使用共享内存、减少bank冲突)。
  6. 善用工具:使用 cuda-memcheck、Nsight 等工具进行调试和性能剖析。

通过遵循这些原则,你可以将许多计算密集型任务有效地移植到GPU上,并充分发挥其并行计算能力。

9:CUDA编程实践与矩阵乘法优化

概述

在本节课中,我们将深入学习CUDA编程的实践技巧,并以矩阵乘法为例,探讨如何将串行算法高效地移植到GPU上,并进行性能优化。我们将从基础概念回顾开始,逐步深入到内存访问模式、线程组织以及具体的优化策略。


CUDA基础概念回顾

上一节我们介绍了CUDA的基本架构。本节中,我们来回顾一些核心概念,以确保后续讨论的连贯性。

  • 主机(Host)与设备(Device):主机指CPU及其内存,设备指GPU及其显存。
  • 全局内存(Global Memory)与共享内存(Shared Memory):全局内存是设备上所有线程块均可访问的显存。共享内存是线程块内部共享的高速内存。
  • 内核(Kernel):在设备上执行的并行函数。使用 __global__ 限定符声明。
  • 线程(Thread):内核的一个并行执行实例。
  • 线程块(Thread Block):一组线程的集合,被调度到一个流多处理器(SM)上执行。
  • 网格(Grid):所有线程块的集合。
  • 线程束(Warp):SM内部调度和执行的基本单位,通常包含32个线程。

内核启动语法示例:

kernelFunction<<<numBlocks, threadsPerBlock>>>(arguments);

其中,blockIdxthreadIdx 是内置变量,用于标识线程和块的位置。blockDimgridDim 则描述了块和网格的维度。


CUDA编程实用技巧

在深入具体例子前,掌握一些实用的编程和调试技巧至关重要。

错误检查

始终检查CUDA API调用的返回值。这能帮助快速定位问题。

以下是使用宏进行错误检查的示例:

#define gpuErrchk(ans) { gpuAssert((ans), __FILE__, __LINE__); }
inline void gpuAssert(cudaError_t code, const char *file, int line) {
   if (code != cudaSuccess) {
      fprintf(stderr, "GPUassert: %s %s %d\n", cudaGetErrorString(code), file, line);
      exit(code);
   }
}
// 使用方式
gpuErrchk( cudaMalloc(&d_a, N*sizeof(int)) );

对于内核启动,需使用 cudaGetLastError() 来捕获错误。

调试工具

GDB可用于调试CUDA程序,支持在主机和设备代码间切换。

编码建议

  1. 避免硬编码常量:设备参数(如块大小)应通过运行时查询或计算获得,以提高代码可移植性。
  2. 封装通用操作:例如,将计算网格和块大小的向上取整操作封装为函数。
  3. 确保正确性优先:在并行化前,必须拥有一个经过验证的、正确的串行版本作为基准。优化不应以牺牲正确性为代价。

内存访问与边界检查

在CUDA中,内存访问错误(如数组越界)通常不会像在CPU上那样导致段错误,而是直接产生错误结果,这使得调试更加困难。

因此,必须在代码中主动、大量地添加边界检查逻辑。虽然这会引入少量开销,但能极大简化调试过程。可以考虑将这些检查封装在宏或内联函数中,以便在发布版本中轻松禁用。


案例研究:矩阵乘法优化

我们将以矩阵乘法 C = A * B 为例,演示从CPU到GPU的移植与优化全过程。

第1步:建立基准

首先,我们实现一个简单的CPU串行矩阵乘法。这是我们的正确性基准和性能起点。

性能分析发现,当矩阵规模增大时,由于对矩阵B的列访问不符合行主序的内存布局,导致缓存命中率急剧下降,性能恶化。

第2步:CPU优化 - 预转置

一个简单的优化是对矩阵B进行预转置,将列访问转换为行访问。虽然转置本身需要 O(n²) 时间,但后续的乘法操作(O(n³))能因此获得更好的缓存局部性,整体性能得到显著提升。

公式表示:计算 C[i][j] 时,不再访问 B[k][j],而是访问转置后的 B_T[j][k]

第3步:初始GPU移植

我们将优化后的CPU算法直接移植到GPU。每个GPU线程负责计算输出矩阵C中的一个元素。

计算线程全局索引的典型方法:

int i = blockIdx.y * blockDim.y + threadIdx.y; // 行索引
int j = blockIdx.x * blockDim.x + threadIdx.x; // 列索引

此实现已能利用GPU的大规模并行性,获得远超CPU的性能。

第4步:理解GPU内存访问模式

然而,简单的移植并未考虑GPU的内存特性。我们发现,交换循环中的 ij 索引(即改变线程到矩阵元素的映射关系)会导致性能大幅下降。

原因在于线程束(Warp)的内存访问模式

  • 合并访问(Coalesced Access):当一个线程束中的线程访问连续的内存地址时,这些访问可以被合并为一次(或少数几次)内存事务,效率极高。
  • 非合并访问(Uncoalesced Access):当线程束中的线程访问分散的内存地址时,需要多次内存事务(聚集Gather/散射Scatter),效率很低。

在我们的例子中,合理的索引映射确保了同一线程束的线程访问连续内存(行主序),从而实现了合并访问。

第5步:GPU优化 - 分块计算

受CPU缓存分块优化的启发,我们在GPU上实施类似策略,但目标是为了更好地利用共享内存

算法思想

  1. 将矩阵A和B分成大小相等的子块(Tile)。
  2. 每个GPU线程块负责计算输出矩阵C的一个子块。
  3. 在计算过程中,线程块先将所需的数据块从全局内存协作加载到速度更快的共享内存中。
  4. 线程在共享内存上进行数据计算,减少对全局内存的访问。

这要求线程之间进行同步,以确保数据加载完成后再进行计算。使用 __syncthreads() 屏障。

第6步:进一步优化

在分块基础上,还可以采用更多优化技术:

  • 循环展开:手动展开内层循环,减少循环开销并提高指令级并行。
  • 向量化加载:使用 float4 等类型一次加载多个数据,提高内存总线利用率。

这些优化叠加后,性能得到进一步提升。


性能优化总结与陷阱

本节课我们一起学习了CUDA编程的优化路径。

核心优化策略总结

  1. 从简单正确的实现开始:首先建立一个清晰、正确的版本。
  2. 分析性能瓶颈:使用性能分析工具定位热点。
  3. 优化内存访问:确保线程束内访问连续,充分利用合并访问。善用共享内存减少全局内存访问。
  4. 保持线程束内执行路径一致:避免线程束分化(Thread Divergence),即同一线程束内的线程执行不同的代码分支。
  5. 注意同步:正确使用 __syncthreads(),确保所有线程都到达屏障后再进行下一步操作。忘记同步或部分线程无法到达屏障是常见错误。

需要警惕的陷阱

  • 线程束分化if-elsewhile等可能导致同一线程束内线程执行不同路径,严重降低效率。
  • 共享内存库冲突:当共享内存中多个线程访问同一个内存库(Bank)时会发生冲突,降低访问速度。需要通过内存地址布局来缓解。
  • 隐藏的全局内存访问:即使是计算密集型内核,低效的全局内存访问也往往是主要瓶颈。

通过结合算法优化(如分块)和硬件特性理解(如内存合并),我们可以充分发挥GPU的并行计算潜力。

10:通信成本与优化策略

在本节课中,我们将学习并行计算中的通信成本,并探讨如何通过优化数据布局、减少通信开销以及避免资源争用来提升程序性能。我们将从简单的网格计算示例入手,逐步分析延迟、带宽、流水线和算术强度等核心概念。


通信成本的重要性

上一节我们讨论了如何将计算任务划分为相对独立的块。本节中,我们来看看通信成本如何影响并行程序的整体性能。

通常,即使你有一个绝佳的方法将问题分解成多个部分,但如果数据来回移动的成本过高,这个方法的优势也会被抵消。因此,理解和管理通信成本至关重要。

在共享内存的多核处理器中,线程可以通过共享变量进行通信。而在消息传递的并行系统中(例如通过网络连接的大型集群),处理器之间必须显式地发送和接收消息来交换数据。GPU则兼具这两种特性:在块(block)内部是共享内存,而在块之间则类似于消息传递,需要显式移动数据。


回顾:任务划分与负载均衡

大约一周前,我们讨论了将计算划分为相对独立的工作块(chunks)的一般思想,并确保这些块在不同的处理器或线程之间实现良好的负载均衡。这是实现并行计算的第一步。

任务划分可以静态或动态进行:

  • 静态划分:提前确定如何划分,避免了运行时开销。但如果应用程序的执行特征每次运行都不同,静态划分可能导致负载不均衡。
  • 动态划分:根据系统当时的实时状态动态地分解问题,可以更好地适应变化,但会引入一些运行时开销。

一个重要的建议是:从简单的方案开始,然后根据需要逐步增加复杂性。复杂的方案往往因为开销过大而无法带来实际的性能提升。通过测量来理解性能瓶颈在哪里,并专注于解决这些问题,而不是凭空猜测。


示例:网格平均计算

为了具体说明通信模式,我们来看一个在科学计算(如有限元分析)中常见的简化示例:网格平均计算。

我们假设将一个物理对象划分为网格,并希望根据每个网格点及其邻居的当前值来计算该点的新值。在这个简单例子中,我们计算每个点与其四个直接邻居(上、下、左、右)的平均值。

我们处理的是一个 (N+2) x (M+2) 的网格,其中外围添加了额外的行和列作为边界,以便所有内部点都有完整的邻居,无需特殊条件判断。实际计算在内部的 N x M 网格上进行。

基础并行化方法

最直接的方法是将网格划分为若干行块,块的数量等于线程或处理器的数量。假设我们将网格划分为 P 个行块,每个线程处理一个行块。

在消息传递的世界里,每个块在一个独立的进程上运行,进程之间只能通过网络显式发送消息来交换数据。这通常是一个迭代计算:对整个网格执行平均函数,更新网格状态,然后重复直到收敛。

对于中间的行块(例如线程2),它需要从上方邻居获取其顶部边界行的值,从下方邻居获取其底部边界行的值。同时,它也需要将自己的顶部行发送给上方邻居,底部行发送给下方邻居。顶部和底部的行块则只需要与一个邻居通信。

以下是该算法的伪代码框架,展示了核心的通信和计算模式:

// 伪代码示例:网格平均计算的消息传递模式
// 每个线程分配本地网格空间: (rows_per_thread + 2) x (N+2)
allocate_local_grid(my_id, rows_per_thread, N);

![](https://github.com/OpenDocCN/dsai-notes-pt2-zh/raw/master/docs/cmu-15418-prll-comp/img/925eb112e8f115499b9375f80025c04a_17.png)

while (!converged) {
    // 阶段1:边界交换
    if (my_id != 0) {
        send(top_row, to: my_id-1);
    }
    if (my_id != P-1) {
        send(bottom_row, to: my_id+1);
    }
    receive(ghost_top_row, from: above_neighbor);
    receive(ghost_bottom_row, from: below_neighbor);

    // 阶段2:本地计算(使用包括ghost行在内的数据)
    for i in local_rows {
        for j in columns {
            new_grid[i][j] = average(old_grid[i-1][j], old_grid[i+1][j],
                                     old_grid[i][j-1], old_grid[i][j+1]);
        }
    }

    // 阶段3:计算局部变化并全局同步以检查收敛性
    local_delta = sum_of_abs_changes(new_grid, old_grid);
    send(local_delta, to: master_thread); // 例如线程0
    // 主线程收集所有local_delta,求和,判断是否收敛
    // 主线程广播收敛状态给所有线程
    receive(converged_status, from: master_thread);
}

这个程序展示了两种通信模式:

  1. 邻居间交换边界数据的局部通信。
  2. 所有线程将局部误差发送给主线程(线程0),由主线程聚合并广播全局状态的全局归约(reduce)和广播(broadcast)操作。

使用MPI等消息传递框架可以相对容易地编写这类程序。


发送与接收的语义

理解了基本模式后,我们需要深入了解通信操作本身的语义,不同的语义会影响程序的正确性和性能。

阻塞式发送

从系统实现的角度看,最直接的方式是阻塞式发送,有时也称为“ rendezvous”(汇合)。在这种模式下,发送方和接收方必须同步才能完成传输。

  • 对于接收方:如果尝试接收时没有消息到达,则会阻塞等待。
  • 对于发送方:调用发送函数后,代码会阻塞,直到接收方确认收到消息。这类似于握手协议。

系统实现者喜欢这种方式,因为它所需的缓冲区大小非常明确(最多只需容纳一个消息的数据),避免了缓冲区溢出的风险。然而,阻塞式发送容易导致死锁。例如,在上面的伪代码中,如果所有线程同时尝试发送而没有人尝试接收,程序就会卡住。

解决方法:使用奇偶排序等技术交错发送和接收的顺序。例如,偶数编号的线程先发送后接收,奇数编号的线程先接收后发送,从而打破循环依赖。

非阻塞式发送

另一种方式是非阻塞式发送。调用发送函数后立即返回,不会阻塞发送方线程。发送操作在后台进行。

  • 优势:发送方可以在等待数据传输完成的同时执行其他计算,提高了资源利用率。
  • 挑战:如果发送方生产消息的速度快于接收方处理的速度,可能会导致消息在系统中无限堆积,占用大量缓冲区。通常,非阻塞发送会返回一个句柄(handle),发送方可以通过查询该句柄来了解发送是否已完成。

类似地,也可以有非阻塞式接收,允许接收方发布接收请求后先去处理其他任务,当数据到达时通过回调(callback)机制通知接收方。

非阻塞通信有助于重叠通信和计算,是提高并行程序性能的重要手段。


性能参数:延迟与带宽

现在,让我们从更宏观的视角分析通信性能的两个关键参数:延迟带宽。我们可以通过一个生活中的类比来理解它们。

假设一群人要从旧金山开车到匹兹堡(约4000公里)。

  • 延迟:一辆车从起点到终点所花费的总时间。如果车速是100公里/小时,那么延迟就是 40小时
  • 带宽/吞吐量:单位时间内能够完成运输的人数。如果只有一辆车,带宽就是 每40小时1人

关键点:我们可以通过增加车辆数量(即增加“在路上”的并发任务)来提高带宽,而无需改变单辆车的速度(即延迟)。在任何时刻,如果路上有4000辆车(每辆车相隔1公里),那么带宽就变成了 每小时100人(100公里/小时 / 1公里/辆 * 1人/辆)。这个例子说明,提高带宽通常比降低延迟更容易

当然,我们也可以通过提高车速(降低延迟)来同时改善延迟和带宽。

在计算机系统中:

  • 延迟:完成一次操作所需的端到端时间,例如处理一次缓存缺失、从A点发送消息到B点所需的时间。
  • 带宽:单位时间内可以传输的数据量或完成的操作量,单位通常是字节/秒或操作/秒。

在许多系统中,带宽可以近似表示为 1 / 延迟,但通过并发(如多车道)或流水线等技术,可以在不改变延迟的情况下显著提升带宽。


流水线提升吞吐量

另一个提升吞吐量的强大技术是流水线。我们通过洗衣的例子来说明。

假设洗衣包含三个阶段:

  1. 洗涤:45分钟
  2. 烘干:60分钟
  3. 折叠:15分钟

如果顺序执行,完成一批衣物的总延迟是 120分钟。吞吐量是 每120分钟一批

如果采用流水线:当第一批衣物洗涤完成后(45分钟后),立即开始洗涤第二批衣物。虽然烘干阶段是瓶颈(最慢),但我们可以让洗涤和烘干设备持续工作。

在稳定状态下,系统的吞吐量将由最慢阶段(烘干)的延迟决定,即 每60分钟一批。这样,我们无需增加额外的洗衣机或烘干机(资源),仅通过流水线就将吞吐量提高了一倍。付出的代价是每60分钟需要花15分钟折叠衣服。

在处理器设计中,指令流水线正是利用了这一原理,将指令执行划分为取指、译码、执行、写回等多个阶段,使得处理器能够同时处理多条指令,极大提高了吞吐量。


通信成本模型

将上述概念应用到通信中,我们可以建立一个简单的模型。传输 n 字节数据的总时间 T(n) 可以表示为:

T(n) = T0 + n / B

其中:

  • T0:固定开销。包括建立连接、协议处理等时间,对于长距离通信,还包括光速传播延迟。
  • n / B:传输时间。B 是信道的最大带宽(字节/秒)。

有效带宽 则为:有效带宽 = n / T(n) = n / (T0 + n/B)

从这个公式可以看出:

  • 发送更少、更长的消息有助于分摊固定开销 T0,从而提高有效带宽。几乎所有通信介质都更擅长传输长消息而非短消息。
  • 如果通信路径由多个不同速度的链路组成,那么最慢的链路(瓶颈链路)将决定整体的有效带宽。

通过流水线化的通信协议(例如,在等待前一个消息的确认时就开始发送下一个消息的数据),可以尽可能地让瓶颈链路保持忙碌,从而将有效带宽提升到接近其理论最大值 B


计算中的通信成本层次结构

在计算系统中,通信成本无处不在,并形成一个层次结构:

  1. 寄存器:访问速度最快,容量最小。
  2. 缓存:速度较快,但由多个核心共享,可能引入延迟。它同时也是线程间通信的机制。
  3. 本地内存:速度较慢,容量更大。
  4. 远程内存/其他处理器内存:通过消息传递访问,延迟非常高。

核心原则:为了获得高性能,应尽可能将计算所需的数据保持在最局部、最快的存储层次中,最大化数据的局部性


算术强度:计算与通信的比率

一个用于量化上述原则的关键指标是算术强度

  • 固有通信:由问题本质决定的、无法避免的通信(如网格计算中交换边界值)。
  • 额外通信:由于硬件设计、软件实现或数据布局不理想而引入的非必要通信(如缓存行失效、虚假共享)。

算术强度 定义为:算术强度 = 计算操作数 / 通信字节数

它衡量了每移动一个字节数据,能完成多少有用的计算。高算术强度是高性能计算所追求的

示例:SAXPY (Single-precision A·X Plus Y)

  • 计算:每个元素进行1次乘法和1次加法,共 2次浮点运算
  • 通信:需要读取两个源向量 (x, y) 并写入目标向量 (z),假设每个元素4字节,共 12字节内存访问
  • 算术强度 ≈ 2次运算 / 12字节 ≈ 0.17 运算/字节,这是一个非常低的强度,限制了性能发挥。

提高算术强度的方法

  1. 优化数据划分:在网格计算中,与按行划分相比,按方块划分能减少通信边界(周长)相对于计算面积的比例,从而提高算术强度。通信量从 O(n) 降至 O(sqrt(n))
  2. 循环融合:将多个独立的、遍历相同数据的循环融合成一个循环。例如,计算 (A+B)*(C+D),与其先算A+BC+D再相乘(多次遍历数据),不如在一个循环中一次性完成所有操作,显著减少内存访问次数。
  3. 数据布局优化(分块):将数据在内存中按块重新组织(例如,使用四维数组或分块布局),使得每个处理器主要访问本地连续的内存块,提高缓存利用率和局部性。这有助于对抗虚假共享——当多个处理器访问同一缓存行中的不同无关数据时,由于缓存一致性协议导致的性能下降。
  4. 软件管理的缓存:如在CUDA中,主动将数据从全局内存加载到共享内存中,然后进行密集计算,这相当于手动管理的高速缓存,能极大提高算术强度。


资源争用与热点

争用发生在多个执行单元试图同时访问同一稀缺资源时,它会增加开销并成为性能瓶颈。

示例:假设学生找教授答疑,每人需5分钟。如果学生们同时到达,他们需要排队等待。教授是瓶颈资源,利用率很高,但学生的平均等待时间(延迟)很长。

在并行计算中,类似的热点会严重限制可扩展性。例如:

  • 全局任务队列:所有工作线程从一个中心队列获取任务,该队列的锁会成为热点。
  • 原子操作:多个线程频繁对少数几个计数器进行原子递增(例如,在构建粒子网格的归属列表时),这些计数器会成为热点。

避免争用的策略

  1. 分布式数据结构:使用分布式工作队列,每个线程主要从本地队列获取任务,仅在负载不均时从其他队列窃取任务。
  2. 局部聚合:避免直接更新全局结构。例如,在构建粒子-网格归属关系时:
    • 低效方法1:每个网格单元扫描所有粒子。工作量大且不均衡。
    • 低效方法2:每个粒子原子性地将自己插入到对应网格单元的全局列表中。会产生大量原子操作争用。
    • 高效方法
      1. 每个线程处理一部分粒子,为每个网格单元构建一个局部列表(无争用)。
      2. 然后,使用高效的并行排序(如根据网格单元索引对粒子进行排序)。
      3. 排序后,每个网格单元的粒子在排序数组中连续分布,只需记录起始和结束索引即可“获得”该单元的列表。

这种方法的核心思想是:先用无争用的方式完成局部计算,然后通过高效的数据并行原语(如排序、扫描)将局部结果整合为全局结果。这正是当前课程作业中扫描操作可以发挥作用的地方。


总结

本节课中,我们一起深入探讨了并行计算中的通信成本及其优化策略。

我们首先通过网格平均计算的例子,学习了消息传递程序的基本通信模式。然后,分析了阻塞与非阻塞通信的语义及其对程序正确性和性能的影响。

接着,我们引入了延迟带宽这两个关键性能指标,并通过类比说明了如何通过并发和流水线来提高吞吐量。我们建立了一个简单的通信成本模型 T(n) = T0 + n/B,并指出发送长消息和流水线化的重要性。

我们探讨了计算系统中的存储层次结构,并强调了数据局部性的核心地位。为此,我们引入了算术强度这一关键指标,它衡量了计算与通信的比率。我们学习了通过优化数据划分、循环融合、数据布局和软件缓存等方法来提高算术强度。

最后,我们讨论了资源争用热点问题,它们会严重限制并行扩展性。我们学习了通过使用分布式数据结构和局部聚合后全局整合的策略来避免争用,其中高效的数据并行原语(如排序、扫描)扮演了关键角色。

掌握这些关于通信成本、算术强度和避免争用的概念与技巧,对于设计和实现高效的并行程序至关重要。

11:并行算法案例研究

在本节课中,我们将通过几个代表性的程序案例,学习如何提取并行性,并解决负载均衡与通信开销这两个核心问题。我们将看到,尽管问题领域不同,但解决并行挑战的思路有共通之处。

到目前为止,我们已经学习了并行计算的一般原则,包括执行模型和通信模型。现在,我们将通过具体例子来阐释这些问题,并看看它们在不同领域是如何被解决的。

🌊 海洋模拟

海洋模拟的目标是模拟海洋中的洋流。我们将空间离散化为一个网格,并只考虑海洋的一个水平切片(例如海面)。我们需要随时间更新温度、含氧量等状态参数。

这类物理模拟的挑战在于,要提高空间分辨率,就必须缩短时间步长 ΔT,这导致计算需求急剧增加。

计算的核心部分(图中标红部分)类似于我们之前讨论的:计算每个网格点与其四个邻居的平均值。这个过程需要迭代进行,直到满足收敛条件。

与之前类似,计算分为两种:更新每个元素,以及聚合变化信息以判断收敛。

最佳实践是将空间划分为区块。区块越接近正方形越好,因为计算量与面积成正比,而通信量与周长成正比。我们希望提高算术强度,即减少周长与面积之比。

我们假设存在某种屏障同步机制来保持处理器间的同步。但这会带来成本,包括同步时间以及“掉队者”拖慢整体速度的问题。

工作集的大小取决于时间尺度。在最短时间尺度上,每个处理器需要访问一个网格点及其四个邻居。在扫描行时,工作集是行内数据。更一般地说,一个区块内所有节点的状态可以看作一个工作集。缓存层次结构正是为适应工作集大小随时间变化而设计的。

如果我们按水平条带划分,算术强度不佳。更好的方法是在二维上进行分块。

此外,在全局共享地址空间机器上,如果采用标准的行主序存储整个网格,访问模式可能不佳。更好的做法是在每个区块内部使用行主序,这样每个处理器可以连续地扫描其节点,避免缓存访问交错。但这需要从软件角度对数据进行重排。

下图展示了1996年的测量结果。底部的条纹部分是实际计算时间,顶部的黑色部分是数据交换时间,中间的灰色部分是同步等待时间。可以看到,分块布局显著减少了总时间,这主要是通过减少数据通信成本实现的,同时也略微减少了同步成本,因为它提供了更规则的计算模式,减少了因非均匀内存访问导致的等待。

一些观察结论:这是一个纯粹的静态任务分配,效果很好,因为所有网格点的工作量基本均匀。这种“4D分块”确实改善了通信。

这是一个相当典型的问题,也是最容易并行化的一类:均匀网格、局部通信、负载均衡。它在分布式消息传递环境中也能运行得很好。

🌌 星系模拟(Barnes-Hut算法)

星系模拟的目标是基于引力作用,模拟星系中所有恒星的演化。挑战在于,恒星在星系中的分布并不均匀,而是以各种方式聚集。

直接计算每对恒星间的引力是 O(N²) 的。但观察发现,如果某个星团距离足够远,我们可以将其抽象为一个质点,计算其质心,从而近似其对远处恒星的影响。这催生了 Barnes-Hut算法,其复杂度约为 O(N log N)

算法利用了一种称为四叉树(三维中是八叉树)的数据结构。空间被递归地二分,直到每个方格内只剩一颗恒星(树叶)。内部节点可以计算其区域内所有恒星的总质量和质心。

对于一颗给定的恒星(例如图中红色星),计算其受力时,如果左侧的星团足够远(由参数 θ 控制),就可以用绿色节点(树中的一个内部节点)的聚合信息来近似,而无需遍历其中每一颗星。

算法步骤:

  1. 建树:每个时间步构建(或更新)四叉树,并自底向上计算每个内部节点的聚合信息(总质量、质心)。这是一个 O(N)O(N log N) 的操作,本身具有并行性。
  2. 遍历计算:对于树中的每个叶节点(恒星),遍历树来计算作用在其上的合力。有些恒星(孤立)计算量小,有些(在密集区域)计算量大,因此负载不均衡
  3. 移动:根据计算出的力移动恒星。

由于恒星运动相对较慢,我们可以采用半静态的任务分配策略:在一段时间内,将恒星固定分配给处理器;定期测量各处理器的工作量,如果负载失衡,再重新分配。

利用树结构可以自然地进行空间划分。对树进行遍历会产生一个所有叶节点的线性顺序,这个顺序在空间上是聚集的。我们可以根据这个顺序,将恒星划分为连续的“区域”分配给处理器,如上图所示。

关于数据放置,有两种选择:

  1. 完全随机放置。
  2. 定期重排数据,使每个区域的数据在内存中连续。

测量表明,随机放置效果已经很好(35秒 vs 连续放置的30秒)。随机放置避免了数据重排的开销,并且得益于缓存,局部性仍然不错。这提醒我们,有时对局部性的担忧可能过头,需要实际测量来指导。

另一种空间划分方法是正交递归二分法。它递归地将空间(以及其中的物体)划分为两半,先沿x轴,再沿y轴,以此类推。与四叉树固定划分空间不同,ORB的划分线是变化的,以确保每部分包含(加权后)等量的工作。这种方法在某些应用中可行,效果因参数而异。

🔢 扫描操作

扫描操作是一个古老的并行原语,具有广泛的应用。我们以加法为例:

  • 包含性扫描:输出数组的每个元素是输入数组到该位置(包含)所有元素的和。
  • 排除性扫描:输出数组的每个元素是输入数组到该位置(不包含)所有元素的和。

两者可以轻松转换。

如何并行实现?一个直观但工作低效的算法如下(以包含性扫描为例):

  1. 首先,计算每对相邻元素的和。
  2. 然后,步长加倍,计算间隔一个元素的和(利用上一步的结果)。
  3. 重复此过程,直到步长覆盖整个数组。

这个算法的跨度O(log N),这很好。但总工作量O(N log N),而顺序算法只需 O(N)

我们需要一个工作高效的并行扫描算法。下图展示了一个神奇的 O(N) 工作、O(log N) 跨度的排除性扫描算法。它分为两阶段:

  1. 上行阶段:类似二叉树归约,自底向上计算部分和,但只覆盖部分节点。
  2. 下行阶段:通过巧妙的复制和加法操作,将部分和传播到正确的位置。

其C代码实现虽然看起来复杂,但直接对应了图中的操作。for 循环中的 parallel for 表示该层所有操作可以并行执行。

关于局部性,这个算法并不好,一旦超过最近邻,就会访问更远距离的数据。

在实际处理器数量 P 远小于问题规模 N 的情况下,我们可以采用更高效的混合方法。以两个处理器为例:

  1. 每个处理器对其本地数据顺序执行扫描。
  2. 处理器间交换中间结果(每个处理器最后一个部分和)。
  3. 第二个处理器将接收到的值加到其本地每个元素上。
    这种方法的工作量约为 1.5N,局部性好,且易于推广到 P 个处理器。

在CUDA的线程束级别,由于32个线程是锁步执行的,闲置的计算单元无法节省时间,因此适合使用那个工作低效但跨度最小的扫描算法。代码利用共享内存和(旧版CUDA中隐含的)线程束内同步,在常数步数内完成32个元素的扫描。

我们可以将这个线程束级扫描作为原语,构建更大的扫描:

  1. 将大数据分成块(如128个元素分为4个线程束)。
  2. 每个线程束执行一次扫描,得到其本地总和。
  3. 收集每个线程束的总和,用一个线程束对这些总和执行扫描。
  4. 将扫描得到的前缀和广播回各线程束,每个线程束将其加到自己的本地元素上。
    这种方法可以递归扩展,以处理任意大小的数组,同时保持较少的核函数启动次数。

扫描可以推广为分段扫描,用于处理大小不一的连续数据块。通过引入一个标志位来标记每个子段的开始,并修改扫描规则,使扫描值不穿过标志位,我们就可以复用相同的扫描代码来处理分段计算。

🎨 光线追踪

光线追踪是生成逼真图像的最准确方法之一。它从视点出发,反向追踪光线路径,考虑光线与物体的反射、折射等交互。

常用层次包围盒(类似四叉树/八叉树,但划分不均匀)数据结构来加速光线与物体的求交测试。问题在于,每条光线的路径是独立的,且随着反弹会变得发散,导致并行计算时线程活跃度降低(分歧问题)。

一种技术是包式光线追踪。不是一次追踪一条光线,而是将一组(例如8条)空间上相干的光线打包,同时追踪。算法沿树结构下行,并跟踪哪些光线进入了哪个区域。随着追踪进行,包中的光线可能因为击中不同物体而减少,活跃度下降。

为了应对分歧,可以定期对光线进行重分组:开始时用较宽的包,追踪过程中不断重新打包相干的光线。当光线变得完全不相干时,则退化为单条光线追踪。已有许多技巧使这种方法在GPU上取得了相当的成功。

📚 总结

本节课我们一起学习了几个不同领域的并行算法案例:

  • 海洋模拟展示了如何通过规则分块来处理均匀网格问题,以优化通信和负载均衡。
  • 星系模拟(Barnes-Hut算法)利用四叉树数据结构处理非均匀分布,并通过半静态任务分配成本区域管理来应对负载不均衡。
  • 扫描操作是一个强大的并行原语,我们学习了工作高效和特定硬件(如CUDA线程束)优化的实现方法,以及其扩展分段扫描
  • 光线追踪通过包式追踪动态重分组技术,来管理光线路径的相干性和线程分歧问题。

尽管这些应用领域迥异,但其并行化策略都围绕着设计巧妙的数据结构、实现合理的负载划分以及维持必要的通信局部性这一核心思路。并行计算的挑战在于,你需要一个丰富的工具箱,并懂得在何时选择扫描、何时选择树形划分或其他方法。这些思想更多地与问题的通用特征相关,而非特定的科学领域。

12:CUDA性能分析与优化教程

在本节课中,我们将学习如何分析和优化CUDA程序的性能。我们将从理解性能瓶颈的基本概念开始,逐步介绍使用算法分析、性能剖析器和代码修改这三种方法来诊断问题,并深入探讨如何优化内存访问,特别是本地内存的使用。

性能优化流程概述

上一节我们介绍了并行计算的基本概念,本节中我们来看看如何系统地分析和优化CUDA内核的性能。性能优化的核心是识别并解决限制程序速度的瓶颈。这些瓶颈通常源于内存带宽、指令吞吐量或延迟。

以下是性能优化的基本步骤:

  1. 建立性能基准:首先需要明确性能目标,并了解当前内核的性能状况。
  2. 识别性能瓶颈:分析是内存带宽、指令吞吐量还是其他因素限制了性能。
  3. 理解硬件限制:查阅硬件规格,了解理论上的内存带宽和指令吞吐量上限。
  4. 制定优化策略:根据瓶颈类型,采取相应的优化技术。
  5. 验证优化效果:确保优化确实提升了性能,且没有引入新的问题。

识别性能瓶颈的方法

有三种主要方法可以帮助我们理解内核的性能特征。

1. 算法分析

算法分析是指通过阅读高级语言(如CUDA C)源代码,估算程序的计算强度和内存访问模式。例如,对于一个向量加法内核,我们可能认为它读取两个float(8字节),执行一次加法,然后写入一个float(4字节),总计12字节内存操作对应1次计算指令。

然而,这种方法精度较低,因为它忽略了地址计算、循环控制等底层操作。

2. 使用性能剖析器

性能剖析器(Profiler)通过在程序执行时收集各种计数器来提供性能数据。NVIDIA提供了命令行(nvprof)和图形界面(NVIDIA Visual Profiler)两种工具。强烈建议使用图形界面版本,因为它已经将许多底层计数器汇总成更有意义的指标,降低了误解读的风险。

剖析器提供的关键指标包括:

  • 指令发射数:反映计算吞吐量。
  • DRAM读写次数:反映全局内存带宽使用情况。
  • L2缓存命中/未命中:反映缓存效率。
  • IPC:每时钟周期指令数,衡量计算单元利用率。
  • 全局内存吞吐量:单位时间内传输的数据量,单位通常是GB/s。

重要提示:许多计数器(如每SM的指令数)只针对单个流多处理器(SM)进行采样。在估算整个GPU的性能时,需要乘以SM的总数。而L2和DRAM的计数器通常是全局的。

3. 代码修改

代码修改是一种强大但需要技巧的方法。其核心思想是通过修改源代码,分离出计算和内存操作,分别测量它们的时间。

  • 仅内存版本:移除所有计算逻辑,只保留内存加载/存储操作,用于测量“纯净”的内存访问时间。
  • 仅计算版本:移除所有内存访问,只保留计算逻辑,用于测量“纯净”的计算时间。

比较原始版本、仅内存版本和仅计算版本的执行时间,可以直观地看出计算与内存访问的重叠(隐藏延迟)程度。

  • 如果 时间(原始) < 时间(仅内存) + 时间(仅计算),说明计算和内存访问存在重叠,是理想情况。
  • 如果 时间(原始) ≈ 时间(仅内存) + 时间(仅计算),说明几乎没有重叠,存在优化空间。

挑战:编译器优化可能会将“无用”的代码(如不参与计算的内存访问或不存储结果的计算)消除。我们需要使用技巧(例如,通过运行时变量控制代码路径)来“欺骗”编译器,保留我们想测量的代码。

计算与内存的平衡比

优化的一个关键目标是使程序的计算/内存访问比率与硬件的计算/内存带宽比率相匹配。

  1. 确定硬件比率:从硬件规格书中查找理论峰值。例如,对于某个GPU,其比率可能是 3.76 指令 : 1 字节/周期
  2. 估算程序比率:通过算法分析或剖析器数据,估算你的程序每执行一条指令需要访问多少字节内存。
  3. 比较与分析
    • 如果程序比率 < 硬件比率,意味着程序相对更“渴求”内存,性能受限于内存带宽
    • 如果程序比率 > 硬件比率,意味着程序相对更“渴求”计算,性能受限于指令吞吐量

例如,一个向量加法的朴素算法分析得出比率约为 1 指令 : 12 字节,远低于硬件的3.76:1,这强烈暗示该内核是内存带宽瓶颈

深入内存优化:本地内存

本地内存(Local Memory)是GPU全局内存的一部分,但具有特殊的缓存行为(通常缓存在L1和L2中)。它主要用于两种情况:

  1. 寄存器溢出:当线程需要的寄存器数量超过硬件限制时,编译器会将部分变量“溢出”到本地内存。
  2. 无法放入寄存器的变量:例如,索引在运行时确定的大型数组。

本地内存访问会影响性能:

  • 增加内存流量:溢出到本地内存的变量需要通过内存总线加载/存储。
  • 增加指令数:需要额外的指令来管理这些内存操作。

如何分析和优化本地内存

  1. 识别使用情况:无法从高级代码直接判断。必须在编译时添加特定标志,让编译器报告信息,或通过剖析器查看计数器。
    • 编译标志示例:-Xptxas -v,-abi=no
    • 关键剖析计数器:l1_local_load_hit, l1_local_load_miss, l1_local_store_hit, l1_local_store_miss
  2. 评估影响:计算本地内存访问占总体内存流量的比例。如果比例很小(例如<5%),则优化优先级较低。
  3. 优化策略
    • 增加每线程寄存器数量:通过编译器选项(如-maxrregcount=N)或内核启动配置。但这可能降低活跃线程数(占用率),需要权衡。
    • 调整L1缓存/共享内存配置:可以使用cudaFuncSetCacheConfigcudaDeviceSetCacheConfig来增加用于L1缓存的内存份额,减少用于共享内存的份额,这有助于提高本地内存(缓存在L1)的命中率。
    • 使用非缓存加载:对于其他全局内存访问,使用__ldg()指令或-Xptxas -dlcm=cg标志进行非缓存加载,避免它们污染本可用于寄存器溢出的L1缓存空间。

剖析器计数器注意事项l1_local_load_miss计数器仅在先前对应地址有存储操作时才会递增(否则无法判断是否“未命中”)。因此,在估算由未命中导致的真实内存流量时,通常需要将未命中次数乘以2(一次存储写入+一次加载读取)。

总结

本节课中我们一起学习了CUDA性能分析与优化的系统方法。我们首先明确了性能优化的目标是平衡程序需求与硬件资源。接着,我们掌握了三种诊断工具:算法分析(快速估算)、性能剖析器(实际测量)和代码修改(隔离分析)。我们学会了如何计算和解读计算/内存比率,以判断瓶颈类型。最后,我们深入探讨了本地内存这一常见性能“陷阱”的成因、分析方法和优化策略(调整寄存器数量、L1缓存配置)。记住,优化是一个迭代过程:测量、分析、修改、验证,并始终关注最主要的性能瓶颈。

13:性能评估与扩展性分析

在本节课中,我们将学习如何评估并行程序的性能,并深入理解“扩展性”这一核心概念。我们将探讨不同的性能度量方法、扩展性类型以及在实际项目中分析和优化性能的策略。


性能度量的挑战

上一节我们讨论了并行化的基本模式,本节中我们来看看如何衡量并行程序是否成功。一个常见的指标是加速比,即程序在单处理器上的运行时间与在P个处理器上运行时间的比值。

然而,直接比较并行代码与单线程版本的性能可能产生误导。例如,一个并行实现可能包含锁操作,即使在单核上运行,这些锁调用也会带来开销。因此,公平的比较应该是:并行代码的性能 vs. 最优的单线程代码性能

另一个挑战是问题规模。如果问题规模太小,增加处理器可能无法带来性能提升,甚至导致性能下降,因为每个处理器的工作量不足以掩盖通信开销。

公式: 在网格求解的例子中,每个处理器在每个时间步的计算量约为 n² / P,而通信量约为 n / √P。通信计算比与 1 / √P 成正比。随着P增加,通信开销可能主导总运行时间。


强扩展与弱扩展

在并行计算领域,通常用两种方式来讨论扩展性:

  • 强扩展: 固定问题规模,测量使用更多处理器时程序运行速度的提升。这是我们通常所说的“加速比”。
  • 弱扩展: 随着处理器数量的增加,按比例增大问题规模,目标是保持每个处理器的负载和总运行时间大致不变。这衡量了系统解决更大规模问题的能力。

许多科学计算和工程应用更关注弱扩展,因为它们的目标是利用更多的计算资源来解决以前无法处理的大型问题。


不同约束下的扩展性分析

根据应用场景的不同,性能目标可能受到不同资源的约束。我们以N×N的网格求解为例,分析三种典型约束下的扩展行为:

以下是三种不同的资源约束场景:

  1. 问题约束: 固定问题规模(强扩展)。随着P增加,每个处理器的计算量减少,通信开销相对增加,扩展性最终会受到限制。
  2. 时间约束: 固定总运行时间。目标是利用P个处理器在相同时间内解决一个更大的问题。在这种情况下,可解决的问题规模K大约为 N × P^(1/3)。通信计算比随P增长而恶化,但速度比强扩展慢。
  3. 内存约束: 系统总内存随P线性增长。目标是解决与总内存成比例的最大规模问题。此时,每个处理器的网格尺寸与P成正比,通信计算比可以保持恒定,从而实现良好的扩展性。

核心结论: 在三种场景中,内存约束(弱扩展)通常最容易实现良好的扩展性,而问题约束(强扩展)的扩展难度最大。


性能建模与测量实践

在实际项目中,我们常常需要预测或分析性能。有两种主要的建模方法:

  • 基于踪迹的模拟: 记录程序在一种配置下的执行踪迹(如内存访问序列),然后在模拟器上回放以评估其他配置。缺点是可能过度拟合特定踪迹,且难以反映问题规模变化后的行为。
  • 执行驱动的模拟: 模拟器直接执行程序的一个简化或插桩版本,能够动态适应不同的系统状态和问题规模。这种方法更灵活,但构建和运行成本更高。

当面对大量可调参数时,可以使用帕累托最优曲线进行分析。这条曲线展示了在给定约束下(如面积、功耗),性能(如速度)的最优边界,帮助设计者在各种权衡中找到最佳设计点。


性能分析与优化策略

在优化实际代码时,遵循从简单到复杂的策略是有效的。

首先,实现一个简单、正确的并行版本作为基线。然后,通过测量定位性能瓶颈。一个有用的分析模型是屋顶线模型。该模型描述了在给定机器上,程序性能如何随其算术强度变化。

公式: 算术强度 = 总算术操作数 / 总字节通信量。

在屋顶线模型中:

  • 当算术强度较低时,性能受限于内存带宽,位于模型的“斜坡”部分。
  • 当算术强度足够高时,性能受限于处理器峰值计算能力,位于模型的“屋顶”平坦部分。

你的优化方向应取决于程序在屋顶线模型中所处的位置。

以下是一些诊断瓶颈的实用技巧:

  • 判断计算瓶颈: 在代码中人工增加冗余算术操作。如果运行时间没有明显增加,说明你可能未充分利用计算单元。
  • 判断内存瓶颈: 尝试减少内存访问或改变访问模式(如使用更小的数据集)。如果性能显著提升,说明你受内存带宽或延迟限制。
  • 判断同步开销: 临时移除同步操作(如锁)。虽然结果会错误,但可以暴露出同步机制带来的开销。

此外,充分利用性能分析工具:

  • 使用系统监控工具查看CPU利用率。
  • 利用硬件性能计数器获取缓存命中率、指令周期等低级信息。
  • 在代码关键阶段插入轻量级计时器,了解时间分布。

优化是一个迭代过程。当你对一部分代码进行优化后,瓶颈可能会转移到其他地方,需要重新进行测量和分析。


总结

本节课中我们一起学习了并行程序性能评估的核心概念。我们明确了公平的性能比较应基于最优串行代码。我们深入探讨了强扩展弱扩展的区别,并分析了在问题约束时间约束内存约束下并行程序的不同扩展行为。我们还介绍了性能建模的方法(如踪迹模拟)、分析工具(如屋顶线模型)以及一系列定位和诊断性能瓶颈的实用策略。记住,有效的性能优化是一个由测量驱动、从简单开始、并持续迭代的工程过程。

14:缓存一致性协议 🧠

在本节课中,我们将学习一个介于计算机体系结构和系统设计之间的新主题:缓存一致性。当我们在拥有共享内存但存在多个缓存的大型系统中编写代码时,如何确保所有缓存中的数据保持一致变得至关重要。这对于系统设计和程序性能优化都非常重要。

缓存基础回顾

上一节我们介绍了缓存一致性的重要性,本节中我们来看看缓存的基本工作原理。缓存是一个小型、快速的内存,用于临时存放主内存内容的子集。

在C/C++中,如果一个变量被声明为 volatile,意味着对该变量的写操作会直接写入内存,而不仅仅是寄存器。

假设我们有一个4字节的内存块存储在地址 0x12345604。如果使用典型的64字节缓存行,我们会查看低6位地址来确定该数据在缓存行中的偏移量。在这个例子中,偏移量是4,意味着数据从缓存行的第4个字节开始存放。在小端序中,值 1 会存储为 1, 0, 0, 0

缓存设计中有不同的策略,主要与写操作有关:

  • 写回:写操作只更新缓存。只有当该缓存行因容量问题被替换,或进程结束时,才将数据写回主内存。
  • 写直达:每次写操作都直接穿透缓存,更新主内存。实现更简单,但性能较低,因为大多数数据是读多写少。

另一个策略是写分配非写分配

  • 写分配:当写入一个不在缓存中的地址时,先将整个缓存行读入缓存,然后修改目标字节。
  • 非写分配:写入不在缓存中的地址时,直接写入主内存,不将数据读入缓存。

在单处理器系统中,这些策略工作良好。但在共享内存的多处理器系统中,问题就变得复杂了。

共享内存与一致性问题

在共享内存系统中,我们希望:如果一个处理器写入了位置X,另一个处理器随后读取X,那么第二个处理器应该能看到第一个处理器的写入结果。否则,内存就不是真正共享的。

缓存,特别是写回缓存,使得这个问题更加复杂。因为处理器可以在其本地缓存中持有比主内存中更新的数据状态。

考虑一个例子:处理器P1和P2都读取了初始值为0的变量X,并存入各自的缓存。如果P1将X写为1(即使使用写直达缓存),P2的缓存中仍然持有旧值0,除非我们采取措施。如果使用写回缓存,情况会更混乱,因为写入可能长时间停留在缓存中,不被其他处理器或主内存感知。

这不仅仅是同步问题(例如使用锁),因为问题在于硬件层面可能持有数据的陈旧副本。为了保证性能,这必须在硬件层面解决。

缓存一致性的目标与挑战

我们希望达成的目标是:任何时候从位置X读取,都应该得到由任何处理器写入的最新值

挑战在于,缓存创造了“单一共享内存”的假象,但实际上我们拥有一个内存层次结构。作为缓存设计者,我们的工作就是修复这个问题。

在一个典型的多核处理器中,缓存层次结构可能包括:

  • 每个核心私有的L1和L2缓存。
  • 所有核心共享的L3缓存。
  • 主内存。

核心间的交互主要发生在L2和L3缓存之间,一致性硬件通常插入在此处。

即使在单处理器中,由于内存系统流水线、写缓冲区等因素,保持内存一致性也非易事。在多处理器中,问题更加复杂,因为处理器之间并非紧密同步。

因此,我们需要放宽一些要求,在保证程序顺序的前提下,创造一个规则被遵守的假象。

顺序一致性

一种常被讨论的规则是顺序一致性。它包含两层含义:

  1. 对于单个处理器,其读写操作必须按照程序中的顺序发生。
  2. 所有处理器的所有内存操作,必须存在一个全局的、线性的顺序,使得每个处理器都看到操作按照这个顺序发生。

但这并不是一个易于实现的策略。我们更倾向于用可实现的规则来描述它,这些规则能保证顺序一致性的效果。

以下是三个核心规则:

  1. 处理器顺序:如果处理器P读取地址X,它应该得到P自己最近写入X的值,除非其间有其他处理器写入了X。
  2. 写传播:如果处理器P2写入X,那么经过一段时间后(时间可以模糊),如果处理器P1读取X,它应该得到P2写入的值。这保证了写的最终可见性
  3. 写串行化:对同一地址的多次写入,所有观察到这些写入的处理器必须看到相同的写入顺序。

规则1保证了单处理器的顺序,规则3保证了对同一地址写入的全局顺序,规则2保证了写的最终传播。遵守这三条规则,就能保证顺序一致性。

软件方案(如利用页错误)的粒度太粗(毫秒级),而硬件缓存操作需要在纳秒级完成,因此我们必须寻求硬件解决方案。

监听式缓存一致性协议

今天我们将学习最标准、简单的方案:基于监听的缓存一致性协议。下一讲会学习更具扩展性的基于目录的协议

监听协议的关键思想是:利用共享的互连总线(或其它互连网络),让所有缓存控制器都能“监听”到总线上的内存事务。每个缓存控制器不仅响应本地处理器的请求,还响应来自互连网络的请求。缓存之间通过消息通信,告知彼此它们关心的缓存行的状态变化。

这会在互连上产生额外流量(缓存到缓存的通信),限制了可扩展性,但方案直接,被当今多数多核处理器采用。

简单的写直达协议

首先从一个简单的写直达缓存协议开始。我们暂时忽略缓存行,假设每个地址独立。

以下是协议规则:

  • 处理器读未缓存的数据:从内存读取,并将本地副本标记为有效
  • 处理器读已缓存的有效数据:直接返回缓存值,无总线流量。
  • 处理器写数据:执行总线写,更新内存,并通知其他缓存。其他缓存监听到此写操作后,将自己对应的副本标记为无效
  • 缓存监听到其他处理器的总线写:将自己对应的副本标记为无效

这个协议利用了写直达的特性,所有写都到内存,只需通过无效化消息防止其他缓存持有旧数据。

协议可以分为两类:

  • 无效化协议:处理冲突时,使其他副本无效。
  • 更新协议:处理冲突时,将新数据广播更新给所有持有副本的缓存。

我们主要关注无效化协议。

MSI 协议(写回缓存)

对于性能更高的写回缓存,我们需要更复杂的协议。MSI协议是最基础的写回缓存一致性协议,其名称来源于缓存的三种状态:

  • 修改:缓存行仅存在于当前缓存中,且已被修改(脏),与主内存不一致。该缓存拥有独占所有权
  • 共享:缓存行是干净的(与内存一致),可能存在于多个缓存中。
  • 无效:缓存行不在当前缓存中,或数据已过时。

总线支持三种事务:

  • 总线读:请求一个共享的、只读的副本。
  • 总线写/读独占:请求一个独占的、可写的副本(计划写入)。
  • 总线刷新:将修改的缓存行写回内存。

以下是MSI协议的状态转换规则(以单个缓存控制器对单个缓存行的视角):

  • 处理器请求导致的转换(实线箭头)
    • 读缺失(无效 -> 共享):发起总线读,从内存或其他缓存获取数据,状态转为共享。
    • 写缺失(无效 -> 修改):发起总线读独占,获取数据并独占所有权,状态转为修改。
    • 写命中共享(共享 -> 修改):发起总线读独占,通知其他缓存无效化其副本,状态转为修改。
    • 读命中(共享 -> 共享):无状态变化,无总线事务。
    • 读/写命中修改(修改 -> 修改):无状态变化,无总线事务。这是单处理器性能的关键。
  • 总线监听导致的转换(虚线箭头)
    • 监听到总线读(共享 -> 共享):无状态变化。可能需提供数据(如果自己是所有者)。
    • 监听到总线读(修改 -> 共享):必须刷新数据到总线(供请求者读取),状态降为共享。
    • 监听到总线读独占(共享 -> 无效):必须使自己的副本无效。
    • 监听到总线读独占(修改 -> 无效):必须刷新数据到总线,然后使自己的副本无效。

MSI协议的优势:对于纯粹私有的数据(仅被一个核心访问),其行为类似于单处理器写回缓存,性能良好。
MSI协议的劣势:常见的“读-修改-写”操作(如 i++)需要两次总线事务:先总线读(获取共享副本),再总线读独占(升级为修改)。即使没有其他缓存持有副本,也需要这样。

MESI 协议

为了优化“读-修改-写”操作,引入了 MESI协议,增加了一个状态:

  • 独占:缓存行仅存在于当前缓存中,是干净的(与内存一致)。处理器可以不经总线事务直接将其升级为“修改”状态。

状态转换的关键补充:

  • 读缺失,且无其他缓存持有该行(由总线监听结果得知):状态可直接进入独占,而非共享。
  • 从独占状态写入:无需总线事务,直接转为修改状态。
  • 监听到其他处理器的总线读(独占 -> 共享):状态降为共享,可能需提供数据。
  • 监听到其他处理器的总线读独占(独占 -> 无效):状态转为无效。

MESI协议减少了无竞争写入时的总线流量,是一种常见的优化。

其他变种与考量

  • MOESI 与 MESIF:为了进一步优化数据提供者,引入了“所有者”状态。
    • O:已修改,但与其他缓存共享(需负责提供数据)。
    • F:类似共享,但被指定为未来读请求的数据提供者。
  • 更新协议:如 Dragon 协议。当某个缓存写入时,直接广播新数据给所有共享者,而不是使其无效。这可以减少缓存缺失,但可能大幅增加总线流量,且很多更新可能是无用的(其他处理器可能不再访问该数据)。因此,实践中无效化协议更流行。
  • 多级缓存:在具有L1和L2缓存的系统中,需要维护包含性:L1缓存中的任何行也必须在L2缓存中。这样,L2才能代表L1参与一致性协议。这需要额外的设计来保证。
  • GPU的考量:许多GPU(如NVIDIA)的缓存不提供硬件一致性。它们将片上内存作为可编程的共享内存或软件管理的缓存。这避免了一致性硬件的开销,但将保持正确的责任交给了程序员,以换取更高的性能和更低的成本。

总结

本节课中,我们一起学习了缓存一致性的核心概念。我们了解到,在共享内存多处理器系统中,缓存可能导致数据不一致问题。为了解决这个问题,硬件需要实现缓存一致性协议。

我们重点讲解了基于监听的无效化协议,包括:

  1. MSI协议:定义了修改、共享、无效三种基本状态,是理解更复杂协议的基础。
  2. MESI协议:在MSI基础上增加了独占状态,优化了“读-修改-写”操作的性能。

这些协议通过在缓存控制器之间传递消息,并在共享互连上序列化内存操作,从而保证了写传播写串行化,最终实现了顺序一致性的内存视图。理解这些协议的状态转换对于编写高效、正确的并行程序至关重要,因为不同的共享模式会引发不同的一致性通信开销。

15:作业三介绍与稀疏数据结构 🐀

在本节课中,我们将介绍课程作业三。这是一个关于稀疏数据结构和图算法的并行化项目。我们将探讨如何优化一个模拟大量“老鼠”在迷宫中移动的程序,该程序涉及图、稀疏矩阵等概念。虽然问题本身是虚构的,但它能生成有趣的图形,并且其背后的计算类型与处理稀疏、不规则数据结构的许多现实世界问题所面临的并行挑战类似。

作业概览与时间安排 📅

作业三的截止日期是3月7日。虽然看起来时间充裕,但请注意中途还有一次考试。因此,部分时间需要用于备考。这意味着你应该尽早开始,而不是拖延到最后。

这个作业旨在探索涉及稀疏数据结构(如图、稀疏矩阵)的算法类型。你需要优化一个功能完整但运行缓慢的代码,既要提升其串行性能,也要提升其并行性能。在现实世界中,优化串行代码和并行化通常是协同进行的,因为某些针对串行优化的设计选择会影响到并行化的效果和可扩展性。

我们将使用多核处理器和OpenMP标准来表达并行计算,这部分内容将在后续的课程中详细讲解。

模拟程序:老鼠走迷宫 🧩

让我们来了解一下这个应用程序。想象你有很多老鼠和一个迷宫,迷宫结构用一个图来表示。最简单的图是一个K×K的方形网格,每个节点只与最近的邻居相连。

初始时,所有老鼠都被放置在右下角。然后进行一系列迭代。在每次迭代中,每只老鼠选择下一步的去向:可以留在原地,也可以移动到相邻的位置(由图中的边表示)。

老鼠的选择基于一个奖励函数。这个函数模拟了老鼠的偏好:它们不喜欢过于拥挤,但也不喜欢过于孤单。因此,每只老鼠会根据当前各个位置的老鼠数量(负载因子)计算奖励值,然后根据这些奖励值作为权重,随机选择下一个位置。

可视化表示

我们不需要跟踪每只具体的老鼠,因为所有老鼠的偏好是相同的。为了可视化,我们只需要记录每个网格中有多少只老鼠。

最初,我们可以用文本字符来可视化。例如,用不同字符密度表示老鼠数量。运行多个迭代后,老鼠会从角落扩散开来,但由于它们不喜欢过于孤单,扩散速度会受到限制。

为了让可视化更有趣,我们使用热图。热图是一种标准的数据可视化技术,使用从紫色到红色的光谱来表示数值大小。在这里,黑色表示没有老鼠,深紫色表示有少量老鼠,绿色、蓝色表示中等数量,而红色、橙色则表示数量很多。

核心计算:奖励函数与随机选择

上一节我们介绍了模拟的基本规则,本节中我们来看看核心的计算过程。假设一只老鼠在网格的某个位置,它有五个潜在目的地:原地不动、向上、向下、向左、向右。

它通过以下公式计算每个目的地的奖励值:

reward = 1.0 / (1.0 + alpha * (log(load_factor / L_star))^2)

其中:

  • load_factor = 该位置老鼠数量 / 全图平均老鼠数量
  • L_star 是理想负载因子(例如1.5),表示老鼠喜欢比平均水平稍大的群体。
  • alpha 是一个调整参数(例如0.5)。

这个函数的设计使得在理想负载L_star时奖励为1,当负载因子为0(空位置)时奖励约为0.5,并且随着负载因子变得非常大,奖励值会缓慢衰减而非骤降,这确保了即使初始时大量老鼠聚集在一点,它们也有非零的概率选择移动。

计算完五个目的地的奖励值后,将它们归一化为权重区间。然后生成一个[0, total_weight)之间的均匀随机数,根据该随机数落在哪个权重区间来决定最终去向。

一个重要的特性是:整个程序是完全确定性的。所有随机数生成都基于可预测的种子值。因此,每次运行都会产生完全相同的老鼠移动序列和节点计数。这为正确性验证提供了便利。

更新模式:同步、顺序与批次 🔄

在上一节我们了解了单只老鼠如何做决策,本节中我们来看看如何组织所有老鼠的更新顺序。这会影响模拟的行为和并行潜力。

主要有三种更新模式:

  1. 同步模式:计算所有老鼠的下一步移动(基于当前全局状态),然后让所有老鼠同时移动。这会导致“振荡”现象,因为老鼠们会集体从一个拥挤点移动到另一个点,留下一个空点,如此反复,形成棋盘状的不稳定图案。
  2. 老鼠顺序模式:老鼠0先观察、决策并移动,然后老鼠1基于新的状态(包含了老鼠0的移动结果)进行决策,依此类推。这能产生更平滑、合理的扩散,但完全没有并行性,因为每一步都严格依赖前一步。
  3. 批次模式:这是前两种模式的折衷。将老鼠分成小批次(例如占总数的2%)。在一个批次内,所有老鼠基于同一状态计算并决策,然后同时移动。接着下一个批次再基于更新后的状态进行。批次大小是一个可调参数。同步模式相当于批次大小等于老鼠总数,顺序模式相当于批次大小为1。

对于本作业,你需要优化同步模式批次模式的性能。同步模式虽然行为不太“真实”,但更容易优化和并行化。

程序实现与数据结构 💻

现在我们已经理解了模拟的逻辑,本节中我们来看看它的代码实现和核心数据结构。

程序有两种实现:Python版本和C语言版本。Python版本功能完整但速度慢,用于验证和可视化。C语言版本是性能优化的对象,其输出可以被Python程序读取并生成热图。

图使用一种称为压缩稀疏行的常见数据结构来表示。对于一个有N个节点、M条边的图:

  • neighbors: 一个长度为N + M的数组,按节点顺序连续存储每个节点的所有邻居边(包括自循环边)。对于每个节点,其邻居列表的第一个元素总是它自己。
  • neighbor_start: 一个长度为N + 1的数组,记录每个节点的邻居列表在neighbors数组中的起始索引。neighbor_start[N]指向neighbors数组的末尾,这样方便计算任意节点i的邻居列表范围:从neighbor_start[i]neighbor_start[i+1]-1

这种表示法节省空间,并且能高效地访问任意节点的所有邻居。

程序的核心状态存储在一个state_t结构体中,包含所有老鼠的位置、每个节点的老鼠数量等信息。代码大量使用static inline辅助函数,鼓励编译器内联优化,同时保持代码可读性和可调试性。

性能瓶颈分析

给定的初始C代码性能很低。让我们分析一下原因。对于一只老鼠在某个节点的“下一步移动”计算:

  1. 首先需要遍历该节点的所有D个邻居(包括自身),计算每个邻居的奖励值并求和。这需要D次奖励函数计算。
  2. 然后,为了根据随机数做出选择,需要再次遍历邻居列表,累加奖励值直到超过随机数。平均需要遍历D/2次,并且每次都需要重新计算奖励函数,因为代码没有缓存第一次计算的结果。

因此,对于一只老鼠,平均需要进行大约 D + D/2 = 1.5D 次奖励函数计算。如果有多只老鼠(比如X只)位于同一个节点(例如枢纽节点),它们会重复进行完全相同的奖励值计算,因为节点负载在批次内尚未改变。这造成了巨大的计算冗余。

此外,对于像分形图中度数高达12000的枢纽节点,D/2可能达到6000,使得线性搜索效率很低。

优化的方向包括:缓存中间计算结果、使用更高效的搜索方法(如二分查找,如果权重可以预处理)、以及利用并行性。

图结构:网格、瓦片与分形 🗺️

之前的例子都基于简单的网格图,本节中我们来看看作业中使用的更复杂、更不规则的图结构,它们会带来不同的计算挑战。

  1. 网格图:规则的四邻接网格。非常规则、稀疏,局部性强。易于分区和并行。
  2. 瓦片图:在网格图基础上,将图划分为若干区域。每个区域有一个“枢纽节点”,该节点连接到区域内所有其他节点。这引入了节点度数的差异:普通节点度数约为5,而枢纽节点度数可能很高(例如,如果区域大小为10×10,则枢纽节点度数为99)。这导致了负载的不均匀,枢纽节点往往会聚集更多老鼠。
  3. 分形图:结构更复杂,递归地将区域细分。每个子矩形有自己的枢纽节点。这产生了极大的度数差异:一些顶层枢纽节点可能连接到图中超过一半的节点(度数超过12000),而大多数节点度数仍然很小。这种图非常不规则,老鼠可以通过枢纽节点快速进行长距离移动,对数据局部性和负载平衡提出了挑战。

作业的基准测试将使用这些不同的图结构,以及不同的老鼠初始分布(全部在角落、沿对角线均匀分布、在全图均匀分布),以全面评估你的优化效果。

性能目标与测量 🎯

我们使用“兆鼠移动每秒”作为性能指标:

MegaRat Moves/sec = (R * S) / (T * 10^6)

其中R是老鼠数量,S是模拟步数,T是运行时间(秒)。

你将针对同步模式和批次模式,在多个图结构和初始分布的组合上运行基准测试。最终成绩将基于六个性能数字:同步模式和批次模式各自在三个不同难度基准上的“兆鼠移动每秒”的几何平均值。

给定的初始代码性能很低,尤其是在分形图上。优化目标基于课程讲师所能达到的性能设定。值得注意的是,一些优化在提升串行性能的同时,可能会降低并行加速比,但总体性能(运行时间)仍然是提升的。

开发建议与工具 🛠️

以下是一些帮助你高效完成作业的建议:

  • 尽早开始:先着手优化串行性能,同时思考这些改动对并行化的影响。
  • 性能分析:使用简单的计时库(如提供的cycle.h)在代码中插入计时点,对不同计算阶段(如下一移动计算、老鼠移动更新)进行剖析。这能帮助你准确定位性能瓶颈,无论是串行还是并行部分。
  • 开发环境:你可以在任何机器(如个人电脑、GHC集群)上进行代码开发和串行优化。但为了获得稳定、可重复的并行性能测量,最终测试将在专用的“Late Day”集群上进行。请使用作业说明中描述的批处理技术在该集群上定期进行基准测试。
  • 正确性验证:务必使用提供的回归测试工具。它比较你的C代码输出与Python参考实现的输出。虽然它只测试一些小规模用例,但你应该确保程序在所有情况下功能正确。你可以修改测试文件以添加更多测试用例。
  • 优化思路
    • 缓存重复计算(如节点的奖励值)。
    • 考虑使用更高效的数据结构或算法来替代线性搜索。
    • 利用OpenMP指令将计算任务分配到多个核心。并行化的机会可能存在于:跨不同老鼠、跨不同图节点、甚至跨节点的不同邻居边。

总结 📝

本节课中我们一起学习了作业三的完整内容。我们介绍了一个模拟大量老鼠在图上扩散的应用程序,其核心涉及基于奖励函数的随机决策和稀疏图数据结构。我们探讨了三种更新模式(同步、顺序、批次)及其影响,并分析了程序实现中的性能瓶颈。我们还了解了将使用的不同图结构(网格、瓦片、分形)带来的挑战。最后,我们明确了性能测量方法,并给出了开始优化和并行化的实用建议。记住,关键在于结合串行优化与并行化策略,并始终通过性能剖析和回归测试来指导你的工作。祝你编码愉快!

16:目录一致性协议 🧠

在本节课中,我们将要学习缓存一致性协议中的另一个重要概念——目录一致性协议。我们将探讨它如何解决监听协议的可扩展性问题,并了解其在真实硬件系统中的实现方式。

概述

上一节我们介绍了监听协议及其局限性,特别是总线通信成为系统瓶颈的问题。本节中,我们来看看一种旨在克服这些限制的新方案:目录一致性协议。

伪共享问题回顾

在深入新内容之前,我们先回顾一个关键概念。上周三我们讨论了伪共享问题。这是程序员在使用任何共享内存、共享缓存系统时,最需要理解的事情之一。

伪共享发生在以下情况:程序中的数据块恰好位于同一个缓存行内,但它们被不同核心上的不同进程使用。结果是,它们开始互相争夺这些数据。假设数据是可写的,并且它们都在读写独立的计数器。原则上,这些是完全独立的操作,各自的L1缓存可以加载这些值,系统本应高效运行。但由于它们恰好分配在同一个块内,缓存系统认为这就像需要为每个值提供独占访问一样,因此它们会陷入协议争夺。你看到了保持数据可写、无法共享时会有多大的开销。这会导致性能急剧下降,在现实中你会看到这种情况。

以下是一个例子。想象一个Pthreads示例。我们分配一个与线程数相同大小的计数器数组。然后每个线程独立地递增自己的计数器。另一个版本是,我分配一个计数器数组,但我会用一些内存填充每个计数器,使它们落在不同的缓存行中。在第一种情况下,所有计数器都会落入单个缓存行,存在严重的伪共享,它们不得不为这一行互相争夺。在第二种情况下,每个计数器都有自己的独立行,内存系统将它们各自映射到自己的缓存中,它们都能高效运行。在现实中,你可以看到在一个12核机器上,一种情况耗时5秒,另一种情况耗时2秒。这实际上是一个相对温和的版本,情况可能更糟。所以,如果你不理解这一点,缓存行大小这个特性可能会让你措手不及。你可以编写看起来没问题的代码,代码中甚至不需要同步,但问题是你基本上是在与整个缓存内存系统对抗或误用它。

就像图中所示,典型情况是有一个缓存行跨越了分配给两个不同进程的内存。这也是为什么在之前的例子中,我们讨论了那种四维内存布局,以确保每个处理器获得一个连续的地址范围。这样做的好处之一是保证避免伪共享,因为伪共享最多只会出现在这个范围的端点处,与每一行都出现伪共享相比,影响要小得多。因此,在编程时,即使没有涉及同步,你也需要理解这类问题。

以上就是上周三讲座的内容。我只是想确保你们看到了这一点。

目录一致性协议简介

现在让我们继续。我们来探讨一种新型的缓存内存系统,它试图克服监听协议的一些限制。

那么限制是什么?问题在于可扩展性。在监听协议中,每次缓存中发生一些关键操作时,它都必须广播给所有其他缓存。只要只有半打左右的缓存在监听,这没问题。但当你试图扩展到更大规模时,就会达到一个点,通信流量变得过大,总线通信成为整个系统的中心瓶颈。

目录一致性的思想是尝试提升一个层次,提供一个更接近点对点的解决方案,其中通信只发生在持有数据的处理器和需要数据的处理器之间。这是理想情况。我们将看几种不同的方案,并详细了解它如何在一些你可能在本课程或其他地方遇到的机器上实际实现。你会发现,就像现实世界中的一切一样,它们从不使用任何方案的简单版本,而是使用层次结构,使得小规模示例可以轻松运行,并在系统规模扩大时采用更复杂的解决方案。

正如我上次所说,总线协议的问题是,这些本地缓存都必须通信流量。如果其中一个想要访问某些数据,它必须在总线上发出请求,说明它想要独占读取或非独占读取。然后,如果其中一个想要将其副本升级为只写,它必须广播出去并使其他副本无效。基本上,问题是这个互连结构承载了非常重的流量。在最简单的版本中,互连实际上只是一条总线。总线在此上下文中意味着一组共享的导线,每个人都可以访问。如果有人将一些数据放在上面,那么所有人都会看到该事务并能够接收它。可以把它看作一个中央通信点,所有发送和接收的消息都在这里。在一些更复杂的情况下,它实际上是一个小型网络,通常是一个环,可以在这些元素之间循环传递消息。这样,消息不一定需要绕一整圈,它可以从源跳到目的地,平均而言,你预计它走大约半圈。总线的优势更多在于吞吐量而非延迟,这意味着可以有多个消息在这些环上传输,只为消息提供更多吞吐量,但你仍然会遇到可能变得非常拥堵的问题。想象一下,互连结构就是所有信息被存放的源头,这会变得非常混乱。

NUMA架构与层次化设计

在典型系统中,内存不是一个单一的、位于别处的RAM块。它实际上被分区,每个处理器都有一个独立的内存控制器。这样做的部分原因是为了可扩展性,公司希望能够销售可以按块、按块部署的产品。如果你每个块都有一个独立的内存控制器,那么你就可以根据预算订购尽可能多的块,而不必物理上构建一个单独的内存系统。这有时被称为NUMA机器,即非均匀内存访问,因为处理器读取和写入其本地内存比访问远程内存更快。

对于本次介绍,我们将把处理器视为单核。实际上,在现实世界中,每个处理器本身都是一个多核处理器,具有潜在的本地缓存,它们通过某种总线协议(监听协议)相互通信,然后它们通过像这样的互连结构,使用基于目录的协议,与更大的处理器集群进行全局通信。你会看到一些真实的例子。目前,我们将其视为这种非常简单的扁平结构,但在现实世界中,事物往往是层次化的。

这有时被称为缓存一致性NUMA,意味着我们试图为程序员提供单一全局共享内存的假象。缓存的行为就好像这是一个公共内存引用,当一个写入时,另一个可以读取该数据。

目录协议的基本思想

这个方案的一个版本是采用互连的思想,使其更像一棵树。其思想是,任何可以本地化到互连顶层(例如处理器)的流量,如果所有者就在那里,那么读取请求可以在本地总线上得到满足。但如果所有者在这里,而读取者在那里,那么它就必须通过全局层次结构、树状层次结构来发出请求。这个相当常见的想法是,你尝试使用树状结构来减少中心部分的拥塞。但在最坏的情况下,如果你的工作负载在内存流量方面没有很好地分区,那么树的根部就会成为瓶颈。你可以想象这在NUMA设置中也能很好地工作,其中内存不是在这里,而是被分割成块,这样就有更多情况可以从这种本地化中受益。

但我们现在要看的是,想象一种情况,你想要一个扁平结构,其中有多个处理器,每个处理器都能访问内存,内存在处理器之间分区,并且我们希望能够进行全局缓存一致性访问,我们将使用目录的思想。

目录是一种信息,用于跟踪哪些处理器拥有给定数据块的副本,以便在需要读取时可以从所有者那里访问,在需要使其无效时,可以只向拥有该副本的处理器发送无效消息,而不必广播。

简单目录协议示例

让我们看一个非常简单的版本。想象我有一个处理器,它拥有整个内存空间的一部分。将内存想象成像缓存一样组织成块或行。行是内存中某个块的连续组,比如64字节。逻辑上,你可以将内存地址划分为这些64字节的块,我称之为内存行。我们要做的是为此添加一个目录,对于内存中每一个可能的行(可能有很多),它都有一组位,每个可能的处理器都有一个位,还有一个位表示该行的状态,是脏的还是干净的。这个位向量将说明P个可能的处理器中,哪些在其缓存中拥有这个特定数据行的副本(我们只考虑单级缓存)。然后,你可以想象这样做,整体设计的思路就清晰了。

例如,我们将为每64字节内存定义其主节点,即它存储在P个不同内存中的哪一个。这是内存空间中的最终目的地,可能与数据实际被使用的位置完全无关,但它是整个系统中该部分内存的所在位置。然后,我们将使用术语请求节点,表示想要读取或写入此特定数据块的某个其他处理器。

最简单的情况是读取缺失且该行是干净的。假设这里内存由处理器1拥有,处理器0想要读取它。处理器0会意识到自己没有副本,查看其地址并确定它由处理器1拥有。然后它会发送一条消息,即读取请求:“嘿,我想读取这个数据。”然后处理器1会回应:“好的,这是你的副本。”并会在该特定内存行的位向量的适当位置标记处理器0拥有一个副本。

如果该行是脏的,情况就有点棘手了。想象处理器0发出读取请求,并将其发送给处理器1,因为这是该特定内存位置的主节点。处理器1会说:“哦,有效的副本正由处理器2持有。”现在你可以想象各种场景。但其中一个版本是,处理器1只是回应处理器0说:“哦,抱歉,去问Bob要这个内存。”同时,它会标记……实际上它还没有做。抱歉,这个位是脏位。现在发生的情况是,处理器2……在这个协议版本中(这个想法有各种变体),现在处理器0会说:“嘿,我听说你有这个内存位置。能给我一个副本吗?”处理器2可以直接回应处理器0。在这种情况下,由于我们假设这种属性,即如果存在干净的、可共享的副本,内存将持有该副本。所以会发生的情况是,处理器2……抱歉,是处理器2……也会回应拥有该行的处理器(处理器1)说:“这是副本。我释放它。现在它只是一个共享的可读副本。”因此,我们会将这个位标记为干净,但我们会放入两个共享者,即它由处理器0和2共享。

你可以了解基本思路。让我们再看几个案例。假设处理器3想要写入它,并且它没有副本。假设在这种情况下,它是干净的,但有两个未完成的副本,一个在处理器1,一个在处理器2。同样,你可以想象各种变体。一个版本是,首先处理器0说:“嘿,我想写入这个位置。”并将其发送给拥有该位置的处理器。它会回应一个列表或位向量,说明谁共享这些数据。在这种情况下,由于它是干净的,它也能够回应当前数据是什么。然后在这个版本中,处理器0将负责使其无效,但它可以只向那些拥有此副本的处理器发送无效请求,而不必广播给整个系统。然后它现在拥有……你会看到,它等待直到从这两个处理器收到确认,这样它就不会超前于方案。就像我们之前在写无效协议中看到的那样,你必须确保无效已经发生,这样你就不会让处理器1和2超前访问你想要标记为无效的内存位置。所以,这是一个通用的方案,处理器0会等待直到收到两个确认,然后再继续。现在所有者已将其标记为脏,并由处理器0持有。

协议正确性与挑战

一个关键问题是,你必须说服自己这个协议可以工作,你要避免两个处理器都想写入的情况。由于协议中的某种竞争条件,它们都认为自己拥有可写副本,从而把事情搞乱。这就是为什么这些协议的细节变得相当复杂。在这个简单版本中,我们假设处理器0会继续并告知它确实收到了其他处理器放弃其副本的消息。你必须确保在此期间,没有其他进程发送请求、获取信息并认为自己将获得副本。这涉及到在这些地方有适当的队列,以便事情不会超前。处理器1是所有者,但它没有副本。它会先获取它,然后之后使其无效吗?所以你的意思是,如果这些……我想你的意思是?处理器0是所有者,但它没有副本。处理器1是另一个共享者,它持有副本。但这种情况仍然可能发生。是的,那将是……我们没有讨论这种情况,但这是一个很好的例子。基本上,是对脏行的写入缺失,对吗?所以情况是,它被其他地方拥有,因此处理器1无法提供数据。你必须在协议中增加另一个步骤,说:“首先,我发送到这里。然后它会回来,但它会说:‘哦,那实际上在处理器3的缓存里。’”或者两个缓存。它会说它在那里。顺便说一下,如果它是可写的,如果它不能从主节点获得,这意味着它是一个未共享的可写副本。好消息是,现在处理器1可以发出一条消息说:“把它给我。”意思是释放你的副本并把值给我。处理器1可以提供但需要内存来存储……处理器2的缓存可能有这个值。是的,你可以想象,就像我们之前在一些协议中看到的,你可以加速某些情况,即不访问内存,而是从其他地方找到一个可用的副本。在这个版本中,我保持简单,说如果是读取,我们保持缓存一致,保持一个可读副本;只有在有人拥有独占访问权的情况下才让其分离。所以你可以想象这个协议的扩展,说我想玩所有这些技巧,这样我就不必总是写回内存,除非真的需要。所有这些只是让协议更复杂。就像我说的,当涉及到分离事务时,情况也变得复杂,即你开始做某事,然后在得到响应之前,中间可以有其他流量。所以这些协议在现实生活中变得非常、非常复杂。我只是向你展示最基本的版本。但这些都是非常好的问题,你可以想象想要调整它。

目录协议的优势与开销

目录协议的好消息是它们减少了总线流量。它们使其更加点对点。例如,如果有256个处理器,但只有一个副本在外面,那么我可以只向那一个处理器发送无效消息。因此,对于共享者数量相当少的情况(这相当常见),你可以大大减少流量。如果有很多共享者,那么与广播协议相比,它不会真正为你节省什么,在广播协议中你只是广播。

这是一些来自伯克利Coor及其同事的教科书的统计数据,他们采用了你之前见过的基准测试,并统计了共享者数量的分布。当然,你会发现,在这些情况下,前两个基准测试中,大约80-90%的情况下只有一个副本在外面。在这个Barnes-Hut基准测试中情况不太好,48%是单个共享者,其他情况是多个共享者。然后你会看到一个分布,至少在这种情况下,它们下降得相当快。LU基准测试甚至更明显。我试图弄清楚“零个副本”是什么意思。共享者数量……在……的时候。所以我相信实际上,抱歉我弄错了,零意味着它是私有副本,一意味着有另一个共享者。所以至少在这种情况下,我认为他们只是对共享全局数据进行基准测试,表明在写入时,有另一个副本在外面需要使其无效,就像一个你不断写入的共享缓冲区。所以那实际上意味着存在共享数据。但这里超过一个共享者的情况下降了很多,除了Barnes-Hut。Barnes-Hut的问题在于,你记得它的四叉树结构意味着你经常需要上下遍历树的顶层,因此这些数据往往被大量共享,不过幸运的是,很多流量是只读流量。所以你可以想象,如果你考虑典型程序,你可以想到一些访问模式,并思考这个方案或任何这些方案对这些模式会如何。我们可以定性地做,也可以尝试进行真实的基准测试来看看会发生什么。

很多情况是你所说的主要是只读数据,意味着数据如果被写入,相对罕见。例如,这个Barnes-Hut有很多共享,但很多时间只是上下遍历这棵树,追踪指针而不实际修改任何数据。因为你记得,数据实际上保存在叶子中,树结构更多是如何获取数据的组织结构。

另一类是迁移数据。这就像一个缓冲区,一个处理器写入它,发送信号说“嘿,有一些数据”,然后另一个处理器会读取它。所以我们称之为迁移数据,即在一个地方有一长串写入,然后是一长串读取。顺便说一下,主要是只读的情况在任何合理的缓存方案中都很容易处理,每个人最终都会将干净的副本加载到他们的L1缓存中,并从整个内存系统中获得最大收益。无论采用哪种方案,所有这些方案都会运行良好。迁移数据,你可以看到,对于……的情况运行良好。你进行一堆写入,所以它基本上会移动到写入者的本地缓存中。然后另一端开始读取它,它只会获取副本并将其带回。所以可能有很多流量来进行实际传输,但相对于其他操作,这是一次性操作。所以这些是简单的情况。更有问题的是那些有很多读写的情况。

一个例子是某些被频繁读写的数据。你会看到的是,它往往会移动。想象不同的处理器都在尝试更新,比如一些数据集合,比如计数器之类的。那么会发生的情况是,它会不断跳来跳去。唯一的好消息是,共享者的数量没有机会积累太多。所以在基于目录的方案中,可能拥有其副本的数量实际上保持相当小,因为它们都在互相争夺它。在……之前,你可以积累很多共享者,它通常已经移动到其他地方,并且所有副本都已被无效。所以与总线协议相比,这对于基于目录的协议来说实际上不是一个糟糕的场景。这意味着无效流量的数量将更成比例……它将涉及大量广播。

低竞争锁也不是真正的问题,因为锁的实现通常是一个变量,一个标志,其他处理器在其上自旋,意味着它们不断读取它并等待。然后一旦它被一个处理器获取,它们会看到那个版本,并尝试用一些其他内置的同步来获取它,以防止在多个地方发生这种情况。那么发生的情况是,如果锁被持有很长时间,标志值将迁移到在其上自旋的各个缓存中,它们只会进行本地读取,这不是问题。然后发生写入时,它会使所有副本无效。第一个能够获取那个……副本的处理器将……所以只要竞争不激烈,这不是什么坏事,缓存系统实际上运行得相当好,你最终会得到很多它的共享副本。所以如果有很多……,目录并没有真正帮助你那么多。但这并不经常发生,所以不是什么大问题。你可以想象,糟糕的情况是高竞争块,你最终有很多处理器在等待。突然它改变了,其中一个获取了它,然后它们都自旋,很多在等待,所以你会得到相当多的流量,因为这种情况经常发生,并且有很多共享者,因为所有等待这个锁的进程都会有自己的副本。所以,那种情况。如果真的有大量共享者,那么目录协议相对于全局广播协议的优势就相当小了。

目录存储开销与优化方案

我们目前定义的方案最大的问题是它需要太多内存。因为在我的版本中,我为每64字节内存(无论数字是多少)分配了一个目录条目。不是缓存,而是内存。让我们计算一些数字。假设有2^26个处理器共享它,那么这个位向量有多大?如果我有256位,那是多少字节?有64字节。换句话说,我的位向量占用的字节数和我拥有的数据一样多。我几乎使内存需求翻倍,并且我假设每个内存行都有一个这样的条目,而不是缓存中的每一行。所以无论我的内存是多少,比如32GB内存,我还必须购买32GB内存来存放所有这些目录位。而且我不希望它是慢速的DRAM。这个东西必须相当快,所以显然这不是一个实际可行的好方案。

谁能想到减少内存需求的方法?特别是,如果该行不在任何缓存中,这些向量中的值会是什么?全是零。想想典型系统中的数字,即使是一个大缓存,比如20MB,那已经很大了。而内存可能是32GB,通常在更大规模的系统上甚至更多。所以你拥有的缓存数量实际上远少于内存。想象一下,但如果我们想为整个系统提供足够的目录空间,就内存访问模式而言,最坏情况的分布是什么?是的,正是。你知道,有处理器0的副本,处理器1和处理器2等也有副本,每个处理器有多少?每个处理器都有其整个缓存,其中充满了由该特定进程拥有的地址。但这仍然是数字,是P乘以总数。所以即使我们想象这是20MB,很多,并且我们有1024个处理器,那仍然是……我们回到了我们的问题,我们真的有20GB的共享缓存……所以可能这个数字并没有真正给你带来多大好处,但你可以想象这样的场景,你基本上只有足够的数据在某个缓存中的目录条目。

另一个方案是增加我的……行大小。我们将看到的是增加层次级别,所以即使我有100个处理器,我也不尝试用1000个目录条目来实现,而是使用层次化方案来减少数量,我们将在现实生活中看到这一点。但我们将看另一个方案。实际上,我们只看其中一个。我删除了另一个。所以一个版本是说,看,我的共享者数量实际上相当小。所以我真的不需要分配跟踪可能拥有此副本的每个处理器的最坏情况。我可以减少它,说我将允许最多五个共享副本,并保留一个列表说明这些共享者是谁。但如果超过这个数量,那么我将退回到广播协议。如果你计算数字,五不是一个好数字,对吗?如果我想说五个不同缓存的地址,那需要多少位?在一个1024处理器的系统中,每个是10位,所以5个是50位,这比1024位少得多。这至少是有些希望的,因为正如我们所见,通常你只有非常少的共享副本,或者你可以假设它是无限的,你需要告诉每个人所有事情,你仍然可以想象减少大量的总线流量,如果你只处理这种常见情况并使其回退。所以这是一种有回退的方案,说我总是可以回退到广播。

你可以想象的另一个方案是粗粒度向量,说……你可以想象各种不同的方案,所以你可以说,哦,另一件事是你可以人为地限制某些数据的共享者数量,就像缓存总是说,看,如果缓存中没有空间,我将驱逐某人。所以你可以说,看,我达到了最大共享者数量,其中一个必须离开,你会向最近最少使用的副本发送无效消息。所以你可以想象其他方案,这也适用于我之前描述的那个方案,即你只有足够用于缓存数据的目录条目,你只是限制它,说,嘿,如果来自这个特定内存的数据太多,被分散到各个缓存中,我将限制它,说这就像容量缺失,但不是数据本身的容量,而是你跟踪其方式的能力。

所以在这个领域,你可以想象各种方案来处理至少……通常,在所有系统设计中,特别是在硬件设计中,存在成本效益权衡,你基本上要弄清楚典型工作负载是什么,并确保那些简单且重要的案例得到良好处理,然后你有一些回退机制来处理不太常见的情况,只是让它们以较低效的方式处理。当然,这样做的风险是,一些不知情的程序员可能不知道运行快和运行慢之间的分界线在哪里,通过程序的一些小改动,突然性能变得非常差。这是一个现实生活中的考虑因素,除非你知道这里内部设计发生了什么,否则作为程序员很难真正知道。

稀疏目录与消息优化

哦,这实际上已经讲到了我刚刚谈到的,我们称之为稀疏目录。所以一个系统,你只对某些缓存中持有的数据有目录,基本上每个……所以另一种理想的减少是减少在这些协议期间发送的消息数量。因为消息在两个方面是坏的:一是它们的流量,二是如果有一长串消息,往往会增加延迟。

让我们看几个例子,在之前讨论的版本中,你可能能够做到这一点。我们有一个对脏行的读取缺失。你看到发生的情况是处理器1并没有做太多,它只是回应请求者说:“嘿,这是你需要的信息,现在清理这个烂摊子是你的责任了。”但你可以想象一个版本,其中处理器1承担起责任来启动这个过程。所以它会立即向数据所在的处理器2发送消息,说:“嘿,把你的副本给我。”这是读取还是写入?读取。“把你的副本给我,顺便标记你自己的本地副本。”然后它可以回应原始进程,或者在某些场景下,处理器2会回应两个地方。这取决于你的目标是最小化消息数量还是最小化延迟,因为在这个版本中,它仍然需要两跳……之前它总共需要4跳。所以无论如何,你可以想象其他各种方式,其中所有者、请求者和响应者在每个步骤中相互通信,以尝试要么最小化总步骤数、流量,要么最小化其中最长的步骤链,这将决定延迟。

真实硬件案例:现代Intel处理器

让我们看一些真实的案例。这基本上就是你在GHC机器或现代Intel处理器中的等待日机器上看到的。正如我们之前所说,这些都具有多级缓存的特性,只有最外层缓存实际上是共享的。正如这些虚线所示,在物理设计方面,内存实际上被分区。内存控制器被分区……抱歉,L3缓存被分区。在核心之间,所以当你访问L3缓存时,它们实际上会通信,这就是我们设计中一直所说的内存,对吧?那是共享部分。L1和L2缓存是我们的私有副本,所以正如我们所见,很多流量可以只停留在这两个缓存中。有趣的是,如果存在一些共享,并且必须出去交叉并返回。所以与之前相比,这已经是某种形式的层次结构,它减少了总流量。当我们讨论跟踪谁负责时,现在协议的实际共享发生在这个级别,在大多数情况下,它是一个简单的环通信和监听协议。但可能发生的情况是,比如等待日机器。我不确定JHC机器。等待日机器是所谓的多插槽机器。这意味着有两个物理芯片,每个都是一个完整的至强处理器,就像我们在这里看到的一样。然后它们被连接在一起,以在它们各自的L3缓存之间提供一致性。所以在每一侧都有一个叫做主代理的东西。你可以把主代理看作是代表所有这些结构行事,并调解所有由于内存访问模式而必须从一侧跨越到另一侧的读写请求,因为地址命中了不同的部分。它们没有……我认为没有太多细节可用,但它可能是一个相当简单的基于目录的协议,因为在某些情况下共享者并不多,你可以最多连接四个或八个这样的插槽。你可以把它们放在一起,但共享者的总数并不巨大。所以之前我们讨论的P是256或1024,逻辑上这里的P更接近2、4或8。所以再次强调,层次结构可以产生很大的不同,但它依赖于我的内存访问模式往往非常本地化,如果有很多全局流量,我仍然可能遇到一些相当糟糕的情况。

我们上次提到,这些缓存的一个属性是所谓的包含属性,意味着任何在L1缓存中的东西也在L2缓存中。好消息是,这意味着L2缓存可以在这些协议中交互,可以确切地知道当某些总线流量在这个互连上经过并说“有人有这个的副本吗”时,它知道。这不是默认行为,如果你不特别维护该属性,你可以很容易地设想L1会获得那些已从L2逐出的只读副本,因此它们必须强制在缓存上施加该属性。不过,我刚刚读到,Intel下一代处理器将不再具有该属性,它将允许这种非包含属性,他们没有提供任何技术描述说明如何实现,但可能必须有更多逻辑来跟踪,以便在这个级别(L2和L3之间的调解,这是第一次共享发生的地方)能够正确工作。所以无论如何,这里的要点是,这些目录方案确实被使用,但它们只用在层次结构的较上层,以减少处理器的总数,从而使这些位向量中所需的位数可以大幅减少,不成问题。然后你还会看到它是一个缓存,它只有足够的目录条目,并不是每个可能的内存行都有一个,它只有……是的,类似地,它只对那些有共享副本、某个缓存中有副本的行保持这些条目处于活动状态。

另一种架构:Intel Xeon Phi

还有另一种非常不同的处理器。那实际上是等待日处理器。有14个这样的东西,叫做至强融核,连接到等待日处理器。至强融核是Intel试图与NVIDIA竞争高端计算的产物。其思想是提供一个他们称之为众核系统,意味着在单个芯片上有许多x86核心,并在所有这些核心之间提供缓存一致的内存接口。我们可以访问的版本有点旧,是几年前的,它们有各种代号,我们拥有的机器叫做骑士角落机器。这些实际上类似于……实际上它们就是中国组装成名为天河一号的计算机的那些,多年来它一直是世界上最快计算机,现在它是第二或第三快的超级计算机。我认为它是第二,现在中国有另一个更快的,第三是瑞士实验室的一个,第四是美国的一个。中国在将原始计算能力投入系统方面已经超过了美国。无论如何,这是一个真实的东西,Intel不断完善这个想法,他们计划在美国使用的下一代超级计算机将基于某个版本的至强融核。有一个涉及IBM和NVIDIA的竞争方案。由能源部主导,有两个不同的并行竞争在进行。

那么它的思想是拥有60个左右的核心,全部连接在一起并保持内存一致性,这听起来更像这种可怕的情况,你必须想象……如果你使用基于目录的方案,你必须有一个相当大的目录。这些最初的处理器基于相当旧的设计,可以追溯到10多年前,他们所做的是为它们添加了向量单元。那是下一代,记得我告诉过你有AVX,然后是AVX2,他们没有转向AVX3,而是转向AVX 512。512是向量寄存器中的位数。没人真正关心有多少位,它们是字节,但512更有趣……512位。所以无论如何,它们被设计成每个处理器本身实际上是一个相当差的处理器,只是它有这些可以处理非常大深度的向量单元。

有趣的是,它使用处理器之间的环通信,并以此作为维护缓存一致性的方式。就像我们之前看到的,内存在物理上被分成不同的内存控制器,我认为总共四个或八个。你可以把它们看作是环上的点。这个环只是一个消息可以循环传递的地方,它们可以在缓存控制器或内存控制器之间传递。所以它们做的一件有趣的事情是,它们以此作为内存一致性实现的基础。好消息是,由于它都在一个芯片上,他们可以有一个相当宽的总线,一个64字节的数据总线,不是64位,是64字节。在这个芯片上运行的物理导线,连接它,所以你可以在这个环上获得非常高的带宽和相当低的延迟,因为都是……所以环的好处是,你可以通过发送一条绕整个环运行的消息来有效地进行广播,或者你可以进行更本地化的操作,只走到达目的地所需的距离。这个东西实际上是双向的,所以它可以向任一方向发送消息。

那么发生的情况是,协议看起来更像我们之前看到的全局协议,基于总线的协议,它可以发送形式为“我需要这个的副本”或“你必须使你的副本无效”的消息,这些消息在总线上、在环上发送,如果是无效消息,就绕一整圈;如果只是请求,它可以发出并希望它在附近,然后带着响应返回。所以,它们获得了一种全局行为,但希望某些情况可以更本地化。顺便说一下,这是第一代至强融核。现在你可以想象他们发现,嗯,这并不真的很好,试图让60个左右的核心都在这条总线上发送消息,即使它是一条非常宽的总线,也会产生巨大的流量,这是一个相当严重的瓶颈。我的理解是,他们在设计这个时做得并不出色。所以他们的新一代,叫做骑士登陆,将有一个所谓的网格,你可以通过先朝一个方向走,然后朝另一个方向走,将消息路由到任何两个地方。所以现在没有中央权威,没有简单的方法向每个人发送全局无效消息,因为一切都变成了点对点。这需要一个更复杂的机制,我实际上不知道他们具体如何处理。但可能基于某种类型的目录。所以它是某种基于目录的方案,但没有多少关于如何具体实现的细节可用。所以你看,目录一致性的思想是避免这种中心瓶颈,我称之为基于总线的协议,尽管它通常是某种环,一个中央权威,每个人都必须广播消息。但它带来了成本,实现起来并不容易,协议的复杂性可能相当高,并且你需要存储来跟踪这些的数据量可能相当大,所以正如你在现实生活中看到的,这些往往只在层次结构的较上层实现。

总结与课程应用

这将让你现在开始使用……做作业3时有所体会。你基本上会因为数据如此分散和随机,而做很多到处泵送数据的情况。本质上,想象一个完全随机的排列,如果你试图从一堆随机地址复制地址值,这样做将在这里产生巨大的流量。这肯定会限制程序性能,限制实际并行进程的数量。所以上周五我向你展示了我只能在这些程序上获得大约3.5倍的加速,我认为这是因为内存性能方面有太多的复杂性,无法获得更好的行为。所以提出了一个有趣的问题,你可以尝试重新组织事物、安排事物以增加局部性的方法,局部性在这些方面有很大的不同。

好的,这就是我今天要讲的全部内容。问题……争论……甚至没有。是的,你发现的问题,即使在这个方案中,这也是一个非常好的问题。就像我们用OMP或MPI或Pthreads编写程序。如果我们能知道哪个线程将映射到哪个核心,我们也许能够利用这一点,我们可以尝试以某种匹配的方式放置我们的数据。对于Pthreads,据我所知,你完全没有控制权。在OMP中,我认为……两者OMP和……是的,你可以添加一些控制。一般来说,OMP你会发现它倾向于将事物分成块,你可以对布局工作方式有一些控制。访问模式,你可以给出各种提示。这不容易做到,也不完全可靠,所以这些是人们总是想要调整程序时开始使用的技巧。好问题。好的,今天就到这里。

总结

本节课中我们一起学习了目录一致性协议,它是为了解决监听协议在可扩展性上的瓶颈而设计的。我们探讨了其基本思想,即通过目录跟踪数据块的共享状态,从而将广播通信转变为点对点通信,减少总线流量。我们分析了简单目录协议的工作流程、面临的挑战(如正确性保证和存储开销),并介绍了优化方案如稀疏目录和层次化设计。最后,我们通过现代Intel处理器和Xeon Phi架构的实例,了解了目录协议在真实硬件中的层次化应用。理解这些概念对于编写高效、可扩展的并行程序至关重要,尤其是在处理共享内存访问模式时。

17:缓存一致性协议实现细节 🧠

在本节课中,我们将深入探讨总线型缓存一致性协议的具体实现细节。上一节我们介绍了目录型协议,本节中我们来看看总线型协议在实际实现中遇到的复杂问题。我们将了解如何确保协议的正确性,同时避免死锁、活锁和饥饿等问题,并理解现代总线(如拆分事务总线)的工作原理。

缓存状态机回顾

首先,让我们回顾一下作为许多实际缓存设计基础的MESI状态图。这个状态机为每个处理器的每个缓存行维护状态。

  • 无效:缓存不持有该数据的有效副本。
  • 共享:缓存持有一个干净的、只读的副本,且该数据可被共享。
  • 独占:缓存持有一个干净的副本,并且知道它是唯一持有此数据的缓存。
  • 修改:缓存持有一个“脏”副本,已被本地处理器写入,与主内存中的值不同。

状态转换由两种类型的操作触发:

  • 本地处理器请求(图中黑色箭头):处理器对该数据进行读写访问。
  • 系统其他部分请求(图中蓝色箭头):缓存控制器监听总线上的相干性流量,并据此调整本地状态。

多级缓存与包含属性

在实际系统中,我们面对的是多级缓存。相干性流量通常发生在连接核心与共享缓存(如L2)的互连层级。

为了确保L2缓存控制器能够正确监控和响应总线流量,它需要了解其下级L1缓存的内容和状态。这通常通过包含属性来实现:L1缓存中的内容是L2缓存内容的一个子集。这样,L2缓存就知道L1中有什么数据,以及这些数据是干净还是脏的。

以下是维护包含属性可能遇到的问题:

  • 如果两级缓存都使用标准的LRU替换策略,可能会出现L1中活跃的数据块被L2驱逐的情况,从而破坏包含性。
  • 因此,系统需要人为地强制执行包含策略。例如,当L2决定驱逐一个数据块时,它必须同时强制L1也驱逐该数据块。

包含属性也意味着外部总线流量可能需要向上传播到更高级别的缓存。例如,如果一个外部请求要求独占读取某个数据,而该数据在L2中处于共享状态,那么L2不仅需要将自己的副本置为无效,还需要通知L1缓存也将其副本置为无效。

并发系统常见问题

在设计此类并发系统时,我们会遇到几个经典问题:

死锁
死锁发生在多个代理需要同时持有多个资源,并且形成了一个循环等待的依赖环,导致所有代理都无法前进。避免死锁通常需要破坏其四个必要条件之一:互斥、持有并等待、不可抢占和循环等待。

活锁
活锁与死锁不同,系统并非停滞,而是各个代理在不断行动,但这些行动相互抵消,导致整体上没有进展。一个常见的解决方案是引入指数退避机制,即让代理在冲突后随机等待一段时间再重试,以打破同步振荡。

饥饿
饥饿是指系统整体有进展,但某个特定代理由于优先级不平衡等原因,始终无法获得所需资源而无法前进。解决方案是设计一个公平的调度或仲裁机制,例如使用轮询策略。

总线协议基础

为了简化初始讨论,我们假设:

  1. 每个处理器一次只能发出一个内存请求,必须完成该事务后才能发出下一个。
  2. 使用简单的写回缓存,暂不考虑多级缓存。
  3. 缓存可以通知处理器在它处理总线流量时暂停。
  4. 总线事务是原子的:一旦一个消息出现在总线上,它所需的所有操作都会在没有其他活动干扰的情况下完成。

总线通常是一个共享的物理线路集合,由一个总线仲裁器管理。当多个处理器同时请求使用总线时,仲裁器决定哪个处理器获得访问权。

监听控制器与标签

缓存控制器包含两部分逻辑:一部分代表处理器执行标准的缓存操作,另一部分是监听控制器,负责监控总线上所有流量,查看是否有与本地缓存相关的内存地址。

这两部分逻辑都需要访问缓存的标签和状态信息。为了避免优先级冲突(例如处理器访问与总线监听冲突),一个常见的技巧是复制标签,为两边提供独立的副本,当然这些副本必须保持同步。

总线投票与“或”逻辑

当某个缓存发出读请求时,系统需要知道:是否有其他缓存持有该数据的脏副本?是否有其他缓存持有该数据的共享副本?这需要通过所有缓存“投票”来决定。

以下是实现这种集体响应的机制:

  • 使用逻辑或线路。例如,一条“共享”信号线:如果任何缓存持有该数据的副本,它就会拉低这条线。
  • 类似地,一条“脏”信号线:如果某个缓存持有脏副本,它会拉低这条线(此时应只有一个缓存响应)。
  • 一条“监听挂起”信号线:当缓存需要时间来处理请求时,它会拉高此线,告诉系统等待它完成。

写缓冲区与提交点

为了提高性能,系统会使用写缓冲区。处理器可以将写操作放入缓冲区,然后继续执行,由缓存控制器在总线空闲时再将数据写回。但监听逻辑必须同时检查缓存内容和写缓冲区,以确保一致性。

一个关键概念是提交点。我们需要在复杂的协议中定义一个逻辑时间点,在此之后可以认为一个写操作“已经发生”。对于基于总线的协议,总线事务本身通常作为全局排序和提交点。一旦一个事务(如“读独占”请求)出现在总线上并被接受,系统就承诺会完成这个写操作,后续的读操作将看到这个新值。

拆分事务总线

现代总线为了提升利用率和性能,通常采用拆分事务总线。它将一个完整的事务拆分为独立的请求和响应阶段,从而允许总线在等待一个请求的响应时,处理其他请求。

拆分事务总线带来了新的挑战:

  1. 请求-响应匹配:系统需要跟踪未完成的请求,以便将返回的响应与正确的请求关联起来。这通常通过为每个未完成请求分配唯一标签来实现。
  2. 动态请求更改:一个处理器的请求可能因为之前另一个处理器的请求而需要改变(例如,从“升级为独占”变为“无效化本地副本”)。
  3. 流控制:为了避免缓冲区溢出,引入了否定应答机制。接收方可以发送NACK,要求发送方稍后重试。

以下是拆分事务总线处理一个读请求的简化阶段:

  1. 仲裁:请求方处理器向总线仲裁器请求访问权。
  2. 请求:获得授权后,将地址和命令(读/写)放到总线上。
  3. 决策:所有缓存监听请求,检查自身状态。此阶段可视为提交点
  4. 响应仲裁:持有数据的缓存(或内存)为返回数据而竞争总线。
  5. 数据响应:赢得仲裁后,将数据和对应的请求标签放回总线,完成传输。

通过拆分事务,请求和响应可以乱序完成,只要它们通过标签正确关联即可,这大大提高了总线利用率。

避免死锁的设计

在多级缓存和拆分事务的复杂交互中,容易发生死锁。例如,如果请求和响应共享同一个队列,当队列被请求填满时,响应可能无法入队,导致发送方等待响应,接收方等待队列空间,形成死锁。

一个关键的解决方案是:为请求和响应使用独立的队列。这确保了响应流量永远不会被请求流量阻塞。

总结

本节课中我们一起学习了总线型缓存一致性协议实现中的核心挑战与解决方案。我们回顾了MESI状态机在多级缓存下的运作,理解了包含属性的重要性。我们探讨了并发系统中固有的死锁、活锁和饥饿问题及其应对策略。我们深入剖析了总线的基本工作原理,包括仲裁、监听和基于“或”逻辑的集体响应机制。我们还介绍了写缓冲区对性能的提升以及它带来的设计考量,并明确了“提交点”的概念。最后,我们探讨了现代拆分事务总线如何通过将请求与响应分离来提高效率,以及如何使用独立队列和NACK机制来解决流控制和死锁问题。实现一个正确、高效且健壮的缓存一致性协议是一项极其复杂的工程任务,需要系统性地处理大量并发交互和边界情况。希望通过本讲,你能对处理器背后为了保障数据一致性所付出的精巧努力有更深的体会。

18:OpenMP 并行编程基础

在本节课中,我们将要学习 OpenMP 的基本概念和使用方法。OpenMP 是一个用于管理线程和并行性的编程模型,它通过编译指令(pragma)简化了并行程序的编写。

概述

OpenMP 是一个基于共享内存的并行编程接口,它通过向编译器添加简单的指令来并行化代码。其核心思想是“分叉-连接”模型,程序在并行区域开始时创建一组工作线程,在区域结束时同步并合并这些线程。

并行区域与编译指令

OpenMP 的核心是通过编译指令来标记并行区域。例如,使用 #pragma omp parallel 可以创建一个并行代码块。

以下是使用 OpenMP 并行化一个 for 循环的基本方法:

#pragma omp parallel for
for (int i = 0; i < N; i++) {
    // 循环体
}

这条指令告诉编译器可以并行执行这个 for 循环。当然,是否应该并行化一个循环取决于循环内部是否存在数据依赖。

共享变量与私有变量

在 OpenMP 中,理解变量的共享与私有属性至关重要,这直接影响到程序的正确性。

  • 共享变量:在并行区域开始前声明的变量,默认被所有线程共享。所有线程读写的是同一块内存地址。
  • 私有变量:在并行区域内部声明的变量,或者通过指令显式声明为私有的变量。每个线程都拥有该变量的一个独立副本。

虽然编译器有默认规则(外部声明为共享,内部声明为私有),但最佳实践是始终显式声明变量的共享属性,以避免因代码维护(如移动变量声明位置)而引入的错误。

我们可以使用以下语法来显式指定:

#pragma omp parallel for shared(a, b) private(i, tmp)
for (int i = 0; i < N; i++) {
    // a, b 是共享变量
    // i, tmp 是每个线程的私有变量
}

数据依赖与循环并行化

上一节我们介绍了如何声明变量,本节中我们来看看并行化循环时最重要的注意事项:数据依赖。并非所有循环都可以安全地并行化。

考虑以下循环:

for (int i = 1; i < N; i++) {
    A[i] = A[i-1] + B[i];
}

这个循环存在“流依赖”,即第 i 次迭代的结果依赖于第 i-1 次迭代的结果。如果强行并行化,由于线程执行顺序不确定,将得到错误的结果。OpenMP 编译器可能无法检测所有复杂依赖,因此程序员必须对正确性负责。

屏障同步与 nowait 子句

默认情况下,OpenMP 在并行 for 循环的末尾会设置一个隐式的屏障,所有线程必须在此处同步后才能继续执行后续代码。

有时,我们希望线程在完成自己的那部分工作后能立即继续,而不必等待其他线程。这时可以使用 nowait 子句来消除隐式屏障。

#pragma omp parallel for nowait
for (int i = 0; i < N; i++) {
    // 工作 A
}
// 线程完成工作A后,无需等待即可立即开始工作B
#pragma omp parallel for
for (int i = 0; i < N; i++) {
    // 工作 B
}

使用 nowait 时需要格外小心,必须确保消除屏障后程序的逻辑依然正确,通常要求两个循环之间没有数据依赖。

归约操作

在并行计算中,经常需要将各个线程计算的结果合并成一个最终值,例如求和、求积、逻辑与等。OpenMP 提供了 reduction 子句来简化这一过程。

以下是一个使用归约求和的例子:

int sum = 0;
#pragma omp parallel for reduction(+:sum)
for (int i = 0; i < N; i++) {
    sum += A[i]; // 每个线程计算局部 sum
}
// 循环结束后,所有线程的局部 sum 会自动相加,结果存入全局的 sum 变量

归约操作要求运算符满足结合律交换律。OpenMP 支持 +, *, &, |, &&, ||, max, min 等运算符。

循环调度策略

OpenMP 允许我们指定如何将循环迭代分配给多个线程,这称为调度策略。主要分为静态调度和动态调度。

  • 静态调度:在循环开始前,迭代块就被预先分配给各个线程。开销小,但若每个迭代的工作量不均,可能导致负载不平衡。
    #pragma omp parallel for schedule(static, chunk_size)
    
  • 动态调度:使用一个任务池,线程每完成一个迭代块,就动态地从池中获取下一个块。能更好地平衡负载,但引入了额外的调度开销。
    #pragma omp parallel for schedule(dynamic, chunk_size)
    

选择策略的依据是循环迭代的计算密度是否均匀。均匀则用静态,不均匀则用动态。

内存初始化优化

在非统一内存访问架构上,内存初始化策略会影响性能。一个常见的优化模式是:在并行区域之外分配大块内存(单次操作),但在并行区域之内由多个线程并行初始化各自“附近”的内存部分。

这样做有两个好处:一是利用并行性加速初始化过程;二是让每个线程初始化离自己 NUMA 节点更近的内存,减少远程访问延迟。

double* large_array = (double*)malloc(N * sizeof(double)); // 串行分配
#pragma omp parallel for
for (int i = 0; i < N; i++) {
    large_array[i] = 0.0; // 并行初始化
}

OpenMP API 函数

除了编译指令,OpenMP 也提供了一套运行时 API 函数。例如,omp_get_thread_num() 可以获取当前线程的 ID,omp_get_num_threads() 可以获取线程组的总数。这些函数在需要更精细控制时非常有用。

int tid = omp_get_thread_num();
int nthreads = omp_get_num_threads();

总结

本节课中我们一起学习了 OpenMP 并行编程的基础知识。我们了解到 OpenMP 通过简单的编译指令实现了强大的并行能力,包括管理并行区域、区分共享与私有变量、处理数据依赖、控制同步屏障、执行归约操作以及选择循环调度策略。OpenMP 的“分叉-连接”模型使其非常适合用于并行化规整的循环和代码块,极大地简化了共享内存并行程序的开发。然而,对于更复杂或非结构化的并行模式,我们可能仍需借助更低级的线程库(如 Pthreads)。

19:内存一致性模型 🧠

在本节课中,我们将要学习内存一致性模型。这是一个关于多线程程序如何观察共享内存读写顺序的重要概念。我们将探讨为什么严格的顺序一致性难以实现,以及硬件和软件如何通过更宽松的模型来平衡性能和正确性。

上一节我们介绍了缓存一致性协议,它保证了单个内存位置(缓存行)的读写顺序。本节中我们来看看更宏观的图景——内存一致性模型,它定义了不同内存位置之间操作的全局可见顺序。

顺序一致性的直觉与挑战

内存一致性模型的核心问题是:当多个程序读写共享内存时,它们应该观察到怎样的读写顺序?一个直观的想法是,每次读取都应该看到该内存位置最近一次写入的值。但这在实际的大型系统中并不可行,因为不同操作的延迟差异巨大,要求一个写入立即对所有读取可见是不切实际的。

因此,我们需要一个更宽松的模型。完全无序的模型会让软件开发变得极其困难,所以几乎所有系统都提供某种一致性模型,但为了性能,它们通常是经过“放松”的模型。

一个示例程序

考虑以下程序:

  • 线程0向共享变量 X 写入所有偶数值。
  • 线程1向共享变量 X 写入所有奇数值。
  • 线程2反复读取 X 的值。

问题是,线程2可能观察到哪些值序列?哪些组合是非法的?

关键在于:对于任何一个给定的线程,外部观察者必须能够按照程序编写的顺序看到它的写入。而跨线程之间,必须存在这些线程顺序的某种有效交错。例如,序列 [4, 8, 1] 是合法的,因为偶数和奇数各自保持了顺序。但序列 [9, 12, 3] 是非法的,因为奇数 3 出现在了奇数 9 之后。

这个思想基于一个假设:存在一个全局的、单端口的内存,每一步只能执行一个操作,它会在所有线程的操作中选择一个来执行,从而强制所有线程的操作交错进行,并保持每个线程自身的程序顺序。这被称为顺序一致性

硬件现实:程序顺序的违背

然而,现代处理器为了提取指令级并行性,会进行乱序执行,包括内存操作的乱序执行。处理器内部的硬件(如重排序缓冲区)维持了单线程内顺序执行的假象,但这并不跨线程生效。

写缓冲区的影响

一个关键硬件优化是写缓冲区。当处理器执行存储指令时,它只是将数据放入一个队列(写缓冲区),然后继续执行,由缓存逻辑负责稍后将数据推入内存系统。处理器认为一旦数据进入缓冲区,写操作就“完成”了。

这意味着,一个后续的、对不同地址的读操作,完全可能在之前的写操作对内存系统可见之前就启动并完成。例如:

// 程序顺序
Store A = 5; // 进入写缓冲区
Load B;      // 可能先于 Store A 对系统可见

读操作 B 会窥探写缓冲区,如果是对同一地址 A 的读,它会从缓冲区获取值。但对于不同地址 B 的读,它可以直接从缓存读取,而无需等待 A 的写入完成。

推测执行的影响

分支预测和推测执行也可能导致内存操作不按程序顺序提交。虽然对全局状态(存储)的推测修改会被暂存在写缓冲区中,直到预测被确认,但读操作可以被推测执行,这进一步扰乱了内存操作的全局顺序。

宽松一致性模型带来的问题

硬件不保证严格的程序顺序会导致同步问题。考虑一个典型的同步模式:

// 处理器 1
A = 1;          // 更新数据
ready_flag = 1; // 设置同步标志

// 处理器 2
while (ready_flag == 0) {}; // 忙等待标志
read A;                     // 读取数据

由于写缓冲区和缓存延迟,可能出现以下情况:

  1. 处理器1将 A=1ready_flag=1 放入写缓冲区。
  2. ready_flag=1 的操作更快地传播到了处理器2的缓存。
  3. 处理器2看到 ready_flag==1,跳出循环。
  4. 处理器2读取 A,但此时 A=1 的更新尚未到达它的缓存,因此读到了旧值 0

这违反了我们的直觉:设置标志意味着数据已就绪。

更复杂的依赖链也可能出现类似问题,表明即使在有缓存一致性协议保证单个地址顺序的情况下,跨不同地址的读写顺序也可能出错。

实现顺序一致性的代价

如果我们想在硬件上强制执行经典的顺序一致性,即每个内存操作必须在前一个操作“完成”之后才能开始,那么性能代价会很高。这里的“完成”意味着操作已经深入到内存系统中,足以保证其最终会被执行(例如,已参与缓存一致性协议,开始使其他副本无效)。

这相当于在内存操作的“气球”中,每一个操作之后都打一个结,防止它们上下移动。性能研究表明,这种严格的顺序会导致大量的内存停顿时间,特别是在写操作上。

放宽模型:总存储顺序与部分存储顺序

为了性能,硬件设计者提出了更宽松的模型。

  • 总存储顺序:允许读操作在之前的写操作完成之前就开始(得益于写缓冲区),但写操作之间以及读操作之间仍需保持顺序。这是Intel x86架构采用的模型。
  • 部分存储顺序:进一步放宽,连写操作之间也不需要保持顺序。

使用总存储顺序可以显著减少因等待写操作完成而导致的停顿时间。

弱序与同步操作

从程序员的角度看,我们并不关心所有内存操作的顺序。我们真正关心的是同步点之间的顺序。典型的程序模式是:

...(私有计算)...
获取锁(同步点)
...(修改共享数据,受锁保护)...
释放锁(同步点)
...(私有计算)...

在同步点之间(临界区内),由于数据是私有的或受互斥锁保护,内存操作的顺序无关紧要。关键在于:

  1. 在释放锁(或任何向其他线程发出“数据就绪”信号的操作)之前,必须保证本线程所有对共享数据的修改都已完成并全局可见。
  2. 在获取锁(或任何读取其他线程“数据就绪”信号的操作)之后,必须保证能读到最新的共享数据。

因此,只要在同步操作中插入合适的内存屏障,就能在支持宽松一致性模型的硬件上,让正确编写的程序表现得像在顺序一致性模型下一样。

内存屏障

内存屏障指令(如Intel的MFENCE)在程序中设置一个屏障,确保所有在屏障之前的读写操作都“完成”之后,才允许开始屏障之后的读写操作。它并非全局同步,而是处理器本地的约束。

对于之前的同步示例,正确的屏障插入位置是:

// 处理器 1
A = 1;
MFENCE();          // 确保 A=1 在 ready_flag=1 之前全局可见
ready_flag = 1;

// 处理器 2
while (ready_flag == 0) {};
MFENCE();          // 确保读到 ready_flag=1 之后,再读取 A
read A;

更精细的屏障(如LFENCE仅针对读,SFENCE仅针对写)可以用于优化。

原子操作与锁的实现

在实际编程中,我们通常使用原子操作库,而不是直接使用内存屏障。例如,Intel的XCHG(原子交换)指令不仅原子地交换内存值,其自身就附带了完整的内存屏障效果。

一个简单的自旋锁实现如下:

// 获取锁
while (atomic_exchange(&lock_available, 0) == 0) { // XCHG 隐含 MFENCE
    // 忙等待
}
// 临界区
...
// 释放锁
lock_available = 1; // 普通存储
atomic_exchange(&lock_available, 1); // 使用带屏障的原子操作确保临界区写入在锁释放前可见

使用atomic_exchange释放锁,能保证临界区内的所有写操作在锁被释放(其他线程可见)之前完成。

复杂的无锁算法(如Peterson算法)在宽松内存模型下需要插入大量屏障,难以正确实现,这凸显了使用成熟同步库的重要性。

总结

本节课中我们一起学习了内存一致性模型。我们了解到:

  1. 严格的顺序一致性模型直观但性能代价高。
  2. 现代硬件使用写缓冲区乱序执行来提升性能,这导致了内存操作可能不按程序顺序全局可见。
  3. 宽松一致性模型(如总存储顺序)在硬件层面允许更多重排以提升性能。
  4. 对于软件,关键在于正确使用同步操作(锁、屏障)。通过在同步点插入内存屏障,可以强制关键的内存操作顺序,从而在宽松硬件上构建出顺序一致性的编程模型。
  5. 实际编程中应依赖原子操作和线程库,它们封装了正确的屏障语义,避免了直接处理复杂且平台相关的一致性规则。

内存一致性是硬件性能与软件可编程性之间的权衡。不同的处理器架构(x86, ARM)有不同的模型,因此使用标准库进行同步是保证程序可移植性和正确性的最佳实践。

20:MPI简介与稀疏矩阵向量乘法优化 🚀

在本节课中,我们将学习两个核心主题:消息传递接口(MPI)的基本概念,以及一个稀疏矩阵向量乘法(SpMV)的代码优化案例。我们将首先快速了解MPI,然后深入分析如何通过并行化、向量化等技术来优化一个经典的计算密集型任务。


MPI简介 📨

上一节我们介绍了共享内存模型,本节中我们来看看另一种并行编程范式:消息传递。MPI(Message Passing Interface)是一个为超级计算机设计的标准接口,用于通过进程间通信来解决问题。它与共享内存模型有根本不同。在共享内存中,处理器通过共享内存进行通信。而在MPI模型中,进程通过显式地发送和接收消息来通信,这尤其适用于分布式内存系统或非均匀内存访问(NUMA)开销很大的情况。

MPI本质上是一个异步通信模型。发送者将消息发送给接收者,接收者将其放入队列。之后有两种处理方式:

  • 选项A(阻塞):发送者等待接收者处理完消息并回复。
  • 选项B(非阻塞):发送者发送消息后便继续执行其他任务,之后定期检查是否有回复。

MPI支持这两种模式。阻塞式的发送和接收会使调用进程等待操作完成。非阻塞式的操作则允许计算与通信重叠进行,从而提高效率。

MPI是一个接口规范,而非具体的库。它定义了通信的行为,但具体的实现由不同系统上的各种库来完成。MPI常用于C、C++和Fortran语言。

一个最小但无实际用处的MPI程序如下,它仅初始化并最终化MPI环境:

MPI_Init(&argc, &argv);
// ... 程序主体
MPI_Finalize();

这个程序不会带来性能提升,但它能确认MPI环境工作正常。

MPI的错误处理遵循所使用语言的惯例:C/Fortran返回错误码,C++可能抛出异常。

运行MPI程序没有标准化的环境命令,但通常推荐使用 mpiexec 命令来启动程序,该命令会负责初始化通信和进程管理。

以下是两个非常重要的MPI概念:

  • MPI_Comm_size:获取通信器(communicator)中的进程总数。
  • MPI_Comm_rank:获取当前进程在通信器中的唯一编号(秩)。

通信器定义了一组可以相互通信的进程。MPI_COMM_WORLD 是默认的通信器,包含了所有初始进程。

一个稍好的“Hello World”程序示例如下,它能让每个进程知道自己的秩和总进程数:

int rank, size;
MPI_Comm_rank(MPI_COMM_WORLD, &rank);
MPI_Comm_size(MPI_COMM_WORLD, &size);
printf("Hello from process %d of %d\n", rank, size);

MPI通信基础 🔄

上一节我们介绍了MPI的基本概念,本节中我们来看看具体的通信操作及其潜在问题。

发送者和接收者之间经典的同步问题包括:发送者准备发送时接收者未就绪,或接收者等待时发送者尚未发送数据。缓冲(Buffering)机制用于临时存储突发数据,直到接收者准备好处理。

阻塞通信使编程模型变得简单,发送后等待确认,然后继续执行。但其缺点是会序列化本可并行执行的操作,从而限制性能并可能降低并行度。

为了支持异构环境,MPI定义了自己的数据类型(如 MPI_INT, MPI_DOUBLE)。使用这些类型可以保证数据在不同系统间移植时大小和表示的一致性,也避免了像C语言中 int 类型大小不确定的问题。

MPI标签(Tags)曾用于标识消息类型,接收者可根据标签决定如何处理消息。虽然仍可使用,但现在并不特别常用。

基本的阻塞发送操作 MPI_Send 参数如下:

MPI_Send(void *buf, int count, MPI_Datatype datatype, int dest, int tag, MPI_Comm comm)

参数依次为:缓冲区起始地址、元素数量、数据类型、目标进程秩、消息标签、通信器。

基本的阻塞接收操作 MPI_Recv 参数如下:

MPI_Recv(void *buf, int count, MPI_Datatype datatype, int source, int tag, MPI_Comm comm, MPI_Status *status)

参数增加了源进程秩和状态指针。接收操作也会阻塞,直到收到匹配的消息。

非阻塞通信(MPI_Isend, MPI_Irecv)放松了阻塞限制。发起操作后立即返回,允许进程在后台通信进行时执行其他计算。之后可以使用 MPI_Wait 来等待操作完成,或使用 MPI_Test 来检查操作是否已完成。

MPI_Request request;
MPI_Isend(..., &request); // 非阻塞发送
// ... 执行其他计算
MPI_Wait(&request, MPI_STATUS_IGNORE); // 等待发送完成

MPI_Test 的返回值遵循C/C++惯例:0表示操作已完成(就绪),非零表示未完成。

MPI_Status 对象包含关于操作的额外信息,如消息来源、标签、错误等。MPI_Probe 类似于接收操作,但只获取消息状态而不实际接收数据,相当于“窥探”。

MPI的核心函数并不多,大约6个基本函数(如初始化、终化、发送、接收、获取大小和秩)就能覆盖90%的常用场景。加上非阻塞操作和探针,总共可能也就9个函数。使用MPI的真正挑战不在于库函数本身,而在于如何设计基于消息传递的问题解决算法。

消息传递是一种强大的问题解决范式,它允许“软同步”。进程完成一大块工作后,通过发送消息来传递结果,从而实现同步,而不是通过争用共享内存来同步。

除了点对点通信,MPI还支持集体通信操作:

  • MPI_Bcast:将消息从根进程广播到通信器中的所有进程。
  • MPI_Reduce:将来自所有进程的数据通过一个操作(如求和MPI_SUM、求最大值MPI_MAX)进行归约,并将结果存储在根进程中。

这些集体操作的性能(如广播如何随进程数扩展)取决于底层的硬件实现(如共享总线、点对点网络),MPI接口本身并不规定实现方式。


死锁与MPI总结 ⚠️

上一节我们介绍了MPI的通信操作,本节中我们需要注意一个关键问题:死锁。

任何时候使用阻塞通信都需要小心死锁,它们会以意想不到的方式出现。考虑以下代码:

// 进程 0
MPI_Send(buf1, ..., 1, ...);
MPI_Recv(buf2, ..., 1, ...);
// 进程 1
MPI_Send(buf3, ..., 0, ...);
MPI_Recv(buf4, ..., 0, ...);

如果两个进程的发送缓冲区都满了,每个进程的 MPI_Send 都会阻塞,等待对方进程的 MPI_Recv 来清空缓冲区。但对方的 MPI_RecvMPI_Send 之后,永远无法执行,这就导致了死锁。

解决此类死锁的最佳方案通常是使用非阻塞I/O。如果必须使用阻塞操作,可以通过重新排序发送和接收的顺序来避免,例如让一个进程先发送后接收,另一个进程先接收后发送,确保通信链路不会同时堵塞。

关于MPI的简介就到这里。总的来说,MPI并不复杂可怕,当你需要时,可以快速上手。它的强大之处在于为分布式内存并行计算提供了一个清晰、通用的编程模型。


稀疏矩阵向量乘法优化案例 🔧

现在,让我们转向一个实际的代码优化案例:稀疏矩阵向量乘法(SpMV)。我们有一个大型的 N×N 稀疏矩阵(即大多数元素为零)和一个向量,需要计算它们的乘积。

由于矩阵是稀疏的,使用稠密表示会浪费大量内存和计算资源。因此,我们采用压缩的稀疏行(CSR)存储格式。以下是该数据结构的关键部分:

typedef struct {
    int nz; // 非零元数量
    int *cols; // 每个非零元所在的列索引
    double *vals; // 非零元的值
    int *row_starts; // 每行非零元在vals中的起始位置
} sparse_matrix_t;

这种表示法只存储非零元素及其位置(行、列),大大节省了空间。

我们首先分析一个标准的顺序实现版本。核心计算循环如下:

for (int i = 0; i < num_rows; i++) {
    double sum = 0.0;
    for (int j = row_start[i]; j < row_start[i+1]; j++) {
        sum += vals[j] * x[cols[j]]; // 关键的三行代码
    }
    y[i] = sum;
}

让我们聚焦于内层循环的关键三行代码(加载值、加载向量元素、乘加)。我们需要分析其计算强度:每次迭代包含2次浮点操作(一次乘法、一次加法)和3次内存加载(vals[j], x[cols[j]], 以及累积到 sum 的读-修改-写,但通常寄存器优化后只算2次关键加载)。

假设在一个3 GHz、具有融合乘加(FMA)单元的处理器上,每个FMA操作延迟为3个周期。那么,每个核心的理论峰值浮点性能约为:3 GHz / 3 cycles/op * 2 FMA单元 = 2 GFlops(每秒20亿次浮点操作)。内存带宽能否跟上?这取决于每次迭代需要加载的数据量和内存系统的吞吐量。在这个例子中,我们可能受限于内存带宽。

接下来,我们尝试使用OpenMP进行并行化。只需添加一行编译制导语句:

#pragma omp parallel for
for (int i = 0; i < num_rows; i++) {
    // ... 循环体
}

OpenMP会自动将循环迭代分割成块,分配给多个线程执行。在一个8核系统上,我们期望最多8倍的加速,但实际可能只获得5.5倍,这是由于并行开销、负载不平衡或内存带宽限制造成的。

OpenMP还支持动态调度(schedule(dynamic)),它将循环迭代分成更小的块,并在运行时动态分配给空闲线程。这对于工作负载不均匀的情况很有用。但如果工作负载均匀,动态调度会增加额外的运行时开销,可能反而降低性能。在我们的SpMV例子中,由于采用了稠密表示的非零元,工作负载大致均匀,因此动态调度可能不会带来显著收益。

OpenMP也支持归约操作。我们可以使用以下指令让每个线程计算局部和,然后自动合并:

#pragma omp parallel for reduction(+:sum)
for (int i = 0; i < num_rows; i++) {
    // ... sum += ...
}

这简化了编程,但归约操作本身也有同步开销。

我们还尝试了使用GCC向量扩展进行手动向量化。代码如下:

typedef double v4df __attribute__((vector_size(32))); // 定义4个double的向量类型
v4df *v_vals = (v4df *)vals; // 假设数据已对齐
v4df *v_x = (v4df *)x;
v4df v_sum = {0};
for (...) {
    v_sum += v_vals[j] * v_x[col_index];
}

我们期望向量化能大幅提升性能,因为它一次处理多个数据。然而实际性能提升并不明显。原因很可能仍然是内存带宽限制。当计算单元因向量化而变得非常高效时,对内存系统的数据供给需求急剧增加,内存带宽成为了瓶颈。此外,还可能存在缓存行对齐、访问模式(间接访问 x[cols[j]])等问题,影响了内存吞吐效率。

最后,我们看一个更高级的双阶段优化策略。该策略将计算分为两个阶段:

  1. 阶段1(并行计算阶段):每个线程独立计算分配给自己的那部分行,将结果累加在线程私有的局部数组中。此阶段无同步开销。
    #pragma omp parallel
    {
        double *local_y = malloc(...); // 线程私有累加器
        // 每个线程计算自己的分区
        for (i = my_start; i < my_end; i++) {
            local_y[i] = ...; // 计算部分和
        }
    #pragma omp barrier // 阶段1结束,同步
    
  2. 阶段2(并行归约阶段):所有线程同步后,它们“调转方向”,每个线程负责将所有线程的局部结果中属于某个子范围的部分求和到一起。
        // 阶段2:垂直归约
        #pragma omp for schedule(static)
        for (i = 0; i < num_rows; i++) {
            for (int t = 0; t < num_threads; t++) {
                y[i] += local_y_from_thread[t][i]; // 概念性代码
            }
        }
    }
    

这种方法避免了在计算阶段进行昂贵的全局归约同步,而是将归约工作也并行化了。这是一个非常优雅的模式。然而,在实践中,即使采用了这种巧妙的模式,性能提升可能仍然受限于终极瓶颈——内存带宽。当所有核心都全力工作时,它们对内存系统的总需求可能超过了系统所能提供的带宽。


总结 📝

本节课中我们一起学习了两个主要内容。

首先,我们快速了解了MPI(消息传递接口)。我们明白了它是一种基于消息通信的并行编程模型,适用于分布式内存系统。我们学习了其核心概念:通信器、进程秩、阻塞与非阻塞通信、以及集体操作如广播和归约。同时,我们也注意到了使用阻塞通信时可能出现的死锁问题。

其次,我们深入分析了一个稀疏矩阵向量乘法(SpMV)的优化案例。我们从简单的顺序实现开始,逐步尝试了OpenMP并行化、动态调度、归约以及手动向量化。通过这个案例,我们观察到一个关键现象:在许多计算密集型任务中,当成功地将计算并行化和向量化后,内存带宽往往成为最终的性能瓶颈。我们还学习了一种双阶段并行归约的优化模式,它虽然优雅,但同样可能受限于内存系统。

这个案例深刻地提醒我们,在追求极致计算性能时,必须密切关注内存访问模式、带宽限制以及数据局部性。优化是一个多层次、需要综合考虑计算和内存特性的过程。

21:性能监控工具 🛠️

在本节课中,我们将学习如何分析和优化程序性能。当你的代码运行缓慢时,仅仅猜测问题所在是不够的。你需要通过测量来获得洞察力,从而理解代码的实际行为。我们将介绍一系列工具,帮助你定位性能瓶颈,从系统级监控到细粒度的指令级分析。

概述 📋

性能分析的第一步是确认问题。你的程序真的慢吗?还是系统负载过高?使用像 top 这样的工具可以快速查看整个系统的状态,包括CPU使用率、内存占用以及当前运行的其他进程。


系统级监控:toptime 📊

上一节我们提到了确认问题的重要性,本节中我们来看看两个基础的系统监控工具。

top 命令提供了一个动态的、实时的系统进程视图。它可以告诉你:

  • 有多少用户登录到机器上。
  • CPU的总体使用率。
  • 每个进程的CPU和内存使用情况。
  • 系统的空闲内存量。

例如,如果你的程序显示使用了 796% 的CPU,这通常意味着它几乎完全占用了8个硬件线程(因为 796% ≈ 8 * 100%)。理解这些百分比的计算方式对于诊断CPU密集型问题至关重要。

time 命令(特别是 /usr/bin/time)则在程序运行结束后提供聚合统计数据。它可以告诉你:

  • 程序运行的总挂钟时间。
  • 程序消耗的总CPU时间(用户态+内核态)。
  • 程序使用的最大内存(常驻集大小)。

以下是一个 time 命令输出的示例:

600% CPU, 33 seconds total time, 6 seconds user time.

这表明一个并行程序使用了相当于6个CPU核心满载的总计算时间,但实际的挂钟时间只有33秒,体现了并行带来的加速。

然而,这些工具只提供了宏观的、聚合的数据。要深入理解“为什么慢”,我们需要进入程序内部。


程序性能分析工具 🔍

上一节我们通过系统工具了解了程序的整体资源消耗,本节中我们来看看专门用于分析程序内部行为的工具。这些工具的核心思想是程序插桩——在代码中插入额外的指令来收集运行时数据。插桩主要有两种方式:编译时插桩和运行时插桩。

阿姆达尔定律与热点分析 ⚡

在深入工具之前,我们必须重温阿姆达尔定律。它告诉我们,对程序任何部分进行加速,其整体收益受限于该部分所占的执行时间比例。

公式整体加速比 = 1 / ((1 - P) + P/S),其中 P 是可优化部分的比例,S 是该部分的加速比。

因此,性能优化的首要任务是找到程序的“热点”——那些消耗了大部分执行时间的代码区域。将优化精力集中在热点上才能获得最大回报。

GProf:函数级分析器 📝

GProf 是一个经典的编译时插桩分析工具。通过在编译时添加 -pg 标志,编译器会在每个函数入口和出口插入计时代码。

程序运行后,会生成一个 gmon.out 文件。使用 gprof 命令分析该文件,你会得到一份报告,其中包含:

  • 每个函数消耗的总时间及其占总时间的百分比。
  • 函数的调用关系图(call graph)。
  • 每个函数被调用的次数。

以下是分析报告可能包含的内容:

% time    cumulative seconds   self seconds   calls   name
70.0      2.10                 2.10           1       build_incoming_edges
30.0      2.70                 0.60           18      page_rank

这份报告清晰地指出,build_incoming_edges 函数占了70%的时间,而核心算法 page_rank 占了30%。这帮助你决定优化重点应该放在哪里。

GProf 的优点是易于使用,但它的粒度较粗(函数级),并且插桩会带来一定的运行时开销。

Perf:基于硬件计数器的分析器 🖥️

现代处理器内部都有一组性能监控计数器,可以统计诸如缓存命中/失效、分支预测错误、执行周期数等底层事件。Perf 是一个强大的命令行工具,可以访问这些计数器。

Perf 的使用主要分为两种模式:

  1. perf stat:收集程序运行期间的聚合计数器值。这是一个很好的起点,用于了解程序的整体行为特征。

    $ perf stat ./my_program
    

    输出可能包括:

    5,000,000,000 cycles                    # 总周期数
    2,000,000,000 instructions              # 总指令数
        0.40  insn per cycle              # 每周期指令数(IPC),较低
        50,000,000 cache-misses           # 缓存失效次数
        24.5% of all cache refs          # 缓存失效率
    

    低IPC和高缓存失效率暗示了可能的性能瓶颈。

  2. perf recordperf report:进行基于事件的采样分析。你可以指定一个事件(如 cache-misses 或默认的 cycles),当该事件的计数器溢出时,Perf 会记录当时的程序计数器(PC)位置。

    $ perf record -e cache-misses ./my_program
    $ perf report
    

    perf report 会启动一个交互式界面,展示哪些函数甚至哪些指令地址最常触发该事件。这能将性能问题精准定位到代码行。

重要提示:由于微架构的延迟,采样点可能稍微偏离实际触发事件的指令,通常在一两条指令之内。分析时需要结合代码上下文进行判断。

通过 Perf,我们可能发现,例如,47%的缓存失效发生在一个名为 edge_map 的自定义函数中,从而明确优化目标。

高级工具:Pin 与 ConTech 🔬

对于更深入或定制化的分析,例如需要完整的内存访问踪迹或并发事件记录,可以使用更高级的动态二进制插桩工具。

  • Pin:由英特尔开发的一个动态二进制插桩框架。它可以在程序运行时,将用户编写的“Pin工具”注入到进程中,从而分析指令混合、生成内存地址踪迹、模拟缓存等。Pin 功能强大但开销较高(可能带来10倍或更多的 slowdown)。
  • ConTech:一个研究型工具(由本讲教授开发),旨在高效记录程序执行的控制流、内存访问和并发事件,生成一个“任务图”,可用于数据竞争检测、一致性协议模拟等高级分析。

这些工具通常用于研究项目或对性能有极端要求的深度优化,因为它们会产生巨大的日志文件和分析开销。


内存与调试工具 🐛

性能问题有时与内存错误(如内存泄漏、越界访问)交织在一起。这些问题会拖慢程序甚至导致崩溃。因此,在追求极致性能前,确保代码正确性是关键。

  • Valgrind (Memcheck):一个重量级的内存调试工具。它通过模拟CPU运行你的程序,可以检测内存泄漏、非法内存访问、使用未初始化值等问题。缺点是速度很慢(10-20倍 slowdown)且会使线程序列化。
  • AddressSanitizer (ASan):一个编译时插桩工具。在编译时添加 -fsanitize=address 标志,编译器会插入检查代码。运行时,它能以比 Valgrind 小得多的开销(约2倍)检测类似的内存错误,并且支持多线程程序。

在优化前,先用这些工具扫清内存错误,可以避免许多难以调试的“灵异”性能问题。


性能分析流程总结 🗺️

本节课中我们一起学习了从发现问题到定位瓶颈的完整工具链。现在,让我们总结一个系统化的性能分析流程:

  1. 确认问题:问题是否可稳定复现?在一个空闲的机器上运行。
  2. 宏观检查:使用 top 查看系统负载,使用 time 获取程序整体资源消耗。确认程序是否真的在全力使用CPU。
  3. 定位热点
    • 如果代码结构清晰(函数分明),使用 GProf 找到消耗时间最多的函数。
    • 使用 Perf stat 了解程序的整体微观特征(IPC、缓存失效率等)。
  4. 深入分析:使用 Perf record/report 对热点函数进行采样分析,精确找到消耗周期或触发缓存失效的指令行。
  5. 结合理论与洞察:根据工具提供的数据(如“大量时间花在原子操作上”或“访问通过双重指针间接进行”),运用你的算法和数据结构知识来设计优化方案(如减少争用、改变数据布局)。
  6. 确保正确性:在优化过程中,使用 ValgrindAddressSanitizer 确保你的修改没有引入内存错误。
  7. 迭代:优化后,重复测量,验证性能是否提升,并寻找下一个热点。

记住,测量优于猜测。工具提供的数据是你进行高效优化的最可靠指南。不要花费20小时去优化一个只占1%时间的I/O例程,而应利用这些工具找到真正拖慢程序的“罪魁祸首”。

22:高性能计算互连网络

在本节课中,我们将要学习大规模并行计算机系统中的关键组成部分——互连网络。我们将探讨不同的网络拓扑结构、路由算法以及数据在网络中传输的基本方式,了解它们如何影响系统的性能、可扩展性和成本。

上一节我们介绍了缓存一致性协议,其中假设处理器和缓存之间通过某种方式通信。本节中我们来看看当系统规模扩大时,这种通信方式如何从简单的总线演变为复杂的网络。


网络基础概念与挑战

当观察大规模计算机系统时,互连网络成为一个重要的研究课题,这在较小规模的系统中通常不会被过多考虑。我们讨论的网络是指将高性能计算系统的计算节点连接起来的网络。这与互联网中常见的网络(如TCP/IP)非常不同,因为这些网络旨在实现紧密的本地化极高的性能。另一方面,它们不像互联网那样需要过多担心故障或连接中断。

在讨论网络时,根据上下文,其含义可能大不相同。之前我们描述缓存协议时,只是假设缓存之间存在某种通信方式,通常简化为总线。但需要记住的是,这些连接点(我们称之为节点)通常是缓存控制器、内存控制器或其他作为处理器代理的系统部件。为了简化,我们今天统称它们为节点。

正如上一讲提到的,最简单的形式是总线。它实际上是一组不同的电线:一组用于发出请求(放置地址),另一组用于返回数据的响应。对于读操作,通常使用物理上独立的总线,以避免死锁的可能性,即大量请求阻塞了潜在的响应,导致系统死锁。

今天,我们将超越简单的总线,探讨如果我们使用真正的网络会是什么样子,这些网络可能有哪些形态,以及我们如何获得更具可扩展性的大型系统网络。如今,这不仅适用于谷歌、Facebook或亚马逊等数据中心或超级计算中心,也适用于单个芯片。对于高性能处理器,芯片内部也需要某种网络来连接所有不同的节点。

网络中的节点可能是处理器、内存控制器、I/O控制器或其他核心。我们不过多关心具体连接什么,而是关心如何连接它们。

设计网络涉及许多问题,选择何种网络取决于具体环境,部分取决于预算,也取决于我们谈论的规模:是连接多核芯片中的四到八个节点,还是连接超级计算机中的一万个节点?因此,针对不同部分,设计选择会非常不同。此外,物理上,在单个芯片上可以负担更多、更“粗”的连接,而在芯片之间则不行,因此随着系统设计层级的提升,设计会变得非常不同。

许多问题使得网络设计成为一个难题:

  1. 性能:包括延迟和带宽。
  2. 能耗:特别是对于片上网络,能耗是一个主要问题。
  3. 可扩展性:公司通常不只生产一种系统。他们希望销售256、512、1024个处理器的系统,并且希望网络设计是可扩展的,无需为每个规模定制特殊的网络。

正如前面提到的,即使在单个芯片上,这个问题也日益突出。例如,GHC机器和Latedays机器中的典型节点都是多核芯片(通常是六核或八核),规模较小,通常使用简单的环网。我们拥有的旧款至强融核(Xeon Phi)是61核机器,更新一代是72核,它们内部也有网络连接,并且两代之间有所不同。

甚至有公司设计出非常简单的核心,以便在单个芯片上放置64个核心,形成一个核心数众多但连接带宽较低的廉价系统。即使是右侧展示的Tegra芯片,也是GPU和ARM处理器的组合,集成了多个ARM处理器,常用于汽车应用等单芯片解决方案。几乎所有现代系统都是多核的,区别只是在于核心数量是2个、4个,还是1000个、10000个。


网络拓扑结构

让我们看看互连网络的不同可能性。首先明确一些术语:

  • 节点:网络的端点,可以是缓存控制器、处理器、内存控制器或I/O设备。
  • 链路:网络中的点对点连接,物理上通常是一束电线以及两端的电子设备,用于来回发送消息。
  • 交换机:一个小型模块,可以根据消息的目的地或其他因素,通过不同的路由连接和转发消息。交换机背后的逻辑定义了路由元素。

以下是设计网络时需要考虑的一些问题:

  1. 拓扑结构:网络的整体图结构是什么?交换机和链路如何连接,以及如何连接到节点?
  2. 路由:如果我想将消息从点A发送到点B,我应该选择哪条路径?选择路径的算法是什么?
  3. 缓冲:实际发送消息意味着什么?一个极端是必须通过网络预留整个路径,一次移动整个消息或部分消息。
  4. 流控制:如何管理拥塞?如何处理在给定时间内试图通过网络推送大量消息的事实?

在讨论网络时,有两种不同的通用实现风格:

  • 直接网络:交换机和节点是同一实体,交换逻辑内置于节点本身。
  • 间接网络:存在一系列完全独立于节点的交换机,形成从一个端点到另一个端点的链路。

一个有用的衡量指标是二分带宽。这意味着如果将网络切成两半,计算有多少连接被切断,这个数量就是二分带宽。例如,对于一个N×N的网络(有N²个节点),如果将其切成两半,大约会切断N条线,因此二分带宽是N,即节点数的平方根。这不错,但并非极好。相比之下,一个简单的环或线性链的二分带宽是1,任何一条线被切断都会断开网络,因此二分带宽非常低。对于像你现在正在处理的、几乎没有局部性的应用程序,试图通过系统传输大量流量时,低二分带宽将成为流量的瓶颈。

另一个问题是路由。有些网络被称为阻塞网络,意味着一条消息可能会阻塞另一条消息的进展。另一类称为非阻塞网络,创建起来要困难得多,它保证任何消息都不会受到其他消息的影响。

对于网络性能,通常关注延迟(消息从点A到点B的时间)与负载(系统中的流量)的函数关系。一般形状是一条曲线:当系统中没有其他消息时,消息会毫无干扰地快速通过。随着消息增多,性能会缓慢下降,并在某个点达到饱和,此时由于拥塞和流量过大,延迟会急剧增加。


常见网络拓扑示例

以下是可能考虑的一些拓扑结构:

总线
物理上我们画一条线,但从图论角度看,它实际上只是一个单一的节点(一个交换机)。总线是一种一次只能处理一个连接的交换机(如果我们将其视为点对点连接,则一次只能有一个发送者和一个接收者)。其优点是广播非常廉价。

  • 优点:易于构建,可以相当容易地向总线添加多个节点(在一定限制内),并具有在缓存协议中非常有用的广播能力。
  • 缺点:可扩展性问题,随着连接增多,带宽将严重受限。在芯片上物理驱动总线也相当昂贵(功耗高)。

交叉开关
这是另一个极端。每个节点都通过直接连接与其他每个节点相连,因此这里有N²个交换机。它允许任何节点通过一跳直接与任何其他节点通信。

  • 优点:这是最佳可能,是一个非阻塞网络。任何连接请求,只需使用交叉开关中特定的交换机即可保证工作。
  • 缺点:明显的可扩展性问题,拥有N²个交换机连接N个节点非常昂贵。但实际上,这种设计仍有使用,例如Oracle(收购自Sun Microsystems)的某些处理器芯片中就集成了交叉开关,它占据了与一个核心相当的面积。

环网
允许消息环绕。其优点是易于扩展(只需将环的大小加倍),并且实际上用于连接Intel处理器中的缓存控制器。直到最近的Skylake处理器之前,都使用环网;Skylake使用了网状网络。我们Waitdays集群中的至强融核(Xeon Phi)也是环网连接,这也是其性能不佳的部分原因。环网设计非常简单,通常用于连接4个或8个元素。

网格
网格对于芯片上的布局很有吸引力,因为它可以自然地布置在二维表面上。路由相当简单(类似于在曼哈顿街区导航)。好消息是它易于扩展到不同尺寸。但网格的一个问题是边缘与中间不同,对于某些类型的问题映射来说可能很尴尬。通常采用的路由算法是先在X维度移动,然后在Y维度移动(或反之),以避免死锁。Intel的Skylake处理器和新的至强融核(Xeon Phi)都使用网格网络。

环面
环面是网格的一种变体,其边缘是连接的(形成一个环),从而提供更均匀的连接性。逻辑上,它是一个环面(甜甜圈形状)。环面的一个巧妙变体是折叠环面,它通过将每个链路的长度拉伸两倍,使得没有特别长的链路,从而在二维表面上实现更均匀的布局,同时保持完全相同的逻辑拓扑。


树是一种非常有用的网络。左侧所示的有时被称为H树,因为其形状像字母H。树可以递归设计,嵌入到平面表面,这对于芯片等场景很友好。但经典树的一个问题是二分带宽非常差(在根部切割只切断一条线,二分带宽为1)。为了解决这个问题,Charles Leiserson(CMU博士)提出了胖树的概念。其思想是在树的较高层级增加更多(更“粗”)的链路,从而提供更多连接性,使得二分带宽可以随系统节点数扩展。胖树的路由算法与普通树基本相同(向上走直到找到共同祖先,然后向下走),只是在有多个选择时可以随机选择。胖树路由并不比树路由困难,因此相当有吸引力。

胖树的一个实际问题是,经典版本需要在网络的不同层级构建不同度数的交换机,这对于可扩展的实用设计来说并不理想。但令人惊讶的是,我们可以重新设计它,使其仅使用固定度数的交换机。这可以通过将多个树合并在一起来实现,例如使用四个树在顶部合并。这种设计在商业世界中非常流行,例如InfiniBand网络。物理上,这些交换机与以太网交换机类似,只是通过重新配置路由软件来实现随机选择和定向路由。

超立方体
超立方体是将网格思想推广到多维。一个d维超立方体可以通过连接两个(d-1)维超立方体的对应节点来构建。其优点是具有非常简单的路由算法:节点地址的每一位代表超立方体中的一个维度,两个节点之间有连接当且仅当它们的地址仅相差一位。路由算法就是一次改变一位地址(从高位到低位或随机顺序),直到到达目的地。超立方体具有良好的二分带宽(随节点数扩展),但物理上无法在三维空间中嵌入超过三维的结构,因此当规模增大时,布线会变得非常密集和复杂。一些超级计算机使用四维超立方体,但每个节点具有更高的度数(非二进制)。

Omega网络
这是一种经典的间接网络设计。逻辑上它看起来像一系列交叉连接的阶段。其结构支持一种路由方案:从最高有效位到最低有效位,遵循“1表示向下,0表示向上”的规则。拓扑上,它与胖树有相似之处。这种思想有许多变体,但在实践中是否被广泛使用尚不确定。


数据交换方式

到目前为止,我们看了网络拓扑的高层视图。现在让我们深入了解如何实际实现消息在这些网络中的移动。

一个有趣的问题是:通过网络移动消息意味着什么?在电话系统中,传统方式是电路交换。在通话期间,会在两个呼叫之间建立一系列专用的电线。即使进行长途或国际通话,也是通过电路交换完成的。如今,在计算机科学中很难找到仍然使用电路交换的例子。

分组交换(互联网的基础)的思想是将消息分解成分组块。从网络的角度来看,一个分组就是一个完整的消息,它本身包含目的地信息、可能的源信息以及数据块。典型的互联网分组大小是1500字节。对于这些类型的网络,我们可能希望看到比消息更低的层级。完整的消息是客户端所考虑的,但这些通常被分解成更小的分组(例如几十字节)。在更低的层级,我们甚至可能将其分解成更小的单元(称为微片流控制数字)进行传播。

一个分组通常包含:

  • 头部:描述目的地、长度、源等信息。
  • 有效载荷:数据。
  • 尾部:可能包含表示消息结束的特殊代码,或包含校验和(奇偶校验信息)以检查传输过程中是否损坏。

我们必须考虑试图通过一个一次只能处理一个分组的交换机发送两个分组的情况。基本选项是:

  1. 缓冲其中一个,让第二个通过。
  2. 丢弃它(在互联网拥塞时可能发生)。
  3. 寻找替代路由

我们主要考虑缓冲,假设必须存储一个分组的信息,直到信道可用。

电路交换基本上必须预留沿途所有链路,然后一旦预留成功就可以快速发送分组。问题是建立连接需要大量时间,并且它严重限制了可以维持的连接数量,因此这是一种非常重量级的方法,现在可能已不再使用。

更传统的方式(例如在互联网中)是存储转发。其思想是每个交换机需要有足够的缓冲来容纳几个分组(可能一个或两个)。存储转发意味着存储一个分组,然后将整个分组发送到下一个交换机,因此每个分组在每一跳都是完整的传输。互联网就是一个存储转发网络。但问题是,每一跳都增加了通过链路发送整个分组的完整成本,因此延迟与跳数成正比,并且需要足够的缓冲来实现。

另一种在这些低级网络中使用的思想是直通路由。其思想是基本上使用流水线:当分组移动时,不是在一个交换机中累积整个分组再发送到下一个,而是立即开始发送。因此,你得到了这种流水线效应。所以,从A到B发送消息的时间与消息长度加上跳数成正比,而不是消息长度乘以跳数。你支付第一跳的成本,但一旦开始,就会持续移动直到整个消息通过。在这方面,它的性能要好得多。

然而,直通路由的问题是它可能会阻塞。如果头部被阻塞,即使可以继续移动分组,使其在网络的不同点慢慢累积,但在任何给定时间都可能占用大量链路。

还有一种思想称为虫孔路由。它将分组分解成比分组本身更小的单元——微片。一个分组会有一个微片作为头部,一系列微片作为有效载荷,一个微片作为尾部。有效载荷中没有路由信息,需要做的是在移动这些微片时跟踪正在使用的连接,直到看到带有特殊标志的尾部,然后可以释放它。这几乎像电路交换,分配一个逻辑信道(通过网络的一条路径),并在此消息活动期间使用它。这实际上用于一些真实的网络设计中。

然而,你可能会遇到阻塞,并且如果考虑不周,可能会在系统中产生死锁:可能有两个或一系列消息在缓冲区上形成循环依赖,导致都无法通过。解决方法是虚拟通道,将信道分成多个虚拟通道,为每个提供足够的缓冲,并具备临时解决争用的能力。通过正确的路由规则(例如,先X后Y),可以为水平流量和垂直流量分配不同的虚拟通道,从而避免死锁。这个想法类似于总线中请求总线和响应总线分离以避免死锁的技巧。它使得构建非常快速和简单的网络成为可能(交换机所需的硬件非常少,缓冲和逻辑都不多)。


总结与趋势

本节课中,我们一起学习了高性能计算互连网络的核心知识。

我们首先定义了网络的基本概念(节点、链路、交换机)和设计挑战(性能、能耗、可扩展性)。接着,我们深入探讨了多种网络拓扑结构,包括简单的总线、全连接的交叉开关、易于扩展的环网网格、更均匀的环面、结构化的胖树、高维的超立方体以及间接的Omega网络,并分析了它们各自的优缺点和适用场景。

然后,我们了解了数据在网络中移动的几种方式:传统的电路交换、互联网基础的存储转发、降低延迟的直通路由以及进一步分解数据的虫孔路由,并提到了使用虚拟通道来避免死锁的策略。

总体趋势是,系统从简单的总线开始,发展到环网,现在正转向网格。但随着系统规模的扩大(无论是片上多核、多芯片封装,还是数据中心级别),这些互连网络的思想正在被重新审视和评估。在网络中实现更高的性能和可扩展性,正成为从芯片设计到大型数据中心构建的关键问题。

23:同步原语

概述

在本节课中,我们将学习同步原语(如锁和屏障)的实现原理,并探讨它们在不同硬件架构(如基于总线的缓存和目录式缓存)上的性能表现。我们将从最简单的忙等待锁开始,逐步分析其问题,并介绍更高效、更公平的同步机制。


课程公告与回顾

上一节我们介绍了缓存一致性协议。本节开始前,我们先回顾一些课程安排和背景信息。

期中考试成绩不理想,责任在于课程设置而非学生。课程将提供一次重考机会,详情请参阅Piazza公告。

关于作业使用的Waitdays机器,我们发现不同主机节点之间存在显著的性能差异。这主要影响多进程(例如12进程)版本的作业性能。提交作业时,输出信息会提示任务是否运行在“正常”性能的机器上。对于作业3和4的评分,我们将采取措施确保一致性。

为了帮助大家更好地掌握课程内容,从本周开始将发布简短的练习题,每周一发布,周五截止。这些练习将计入课程总分。

课程后期将安排两场客座讲座,分别由地震预测高性能计算专家Dave O‘halen和扑克机器人开发者Thomas Sandholme主讲。


线程、进程与硬件执行

在深入同步原语之前,我们需要回顾线程在硬件上的执行方式。

一个处理器可以运行许多线程。在给定的机器上,只有一定数量的线程可以同时处于活跃执行状态。每个核心可以维护一个或多个线程的执行上下文(即超线程技术)。硬件实际能同时处理的线程数受限于其执行上下文的数量。

然而,操作系统通常管理着远多于硬件线程数的活跃进程。例如,一台笔记本电脑可能同时运行着数百个线程。因此,操作系统需要决定哪些线程应该在哪个核心的哪个执行上下文上运行。这个过程涉及从内存恢复线程状态(寄存器值、程序计数器等),开销较大。

超线程(或称同时多线程)技术允许单个核心动态混合执行多个线程的指令流,以提高硬件资源利用率。

例如,Waitdays机器拥有12个物理核心,每个核心支持双路超线程,因此在系统中显示为24个“处理器”。OpenMP默认会尝试使用所有24个硬件线程。

核心要点:线程调度是操作系统的职责,而一旦线程被调度到硬件执行上下文上,其指令执行的混合则由硬件动态管理。


锁的基本概念与实现

现在,我们来看看同步的核心概念之一:锁。

同步通常涉及三个步骤:

  1. 获取:获得锁或某种权限,以访问受保护的资源。
  2. 等待:如果需要锁但无法立即获得,则必须等待。
  3. 释放:完成对受保护资源的操作后,释放锁。

实现等待有两种基本策略:

  • 忙等待:线程循环测试条件,直到条件满足。优点是开销极低,无需操作系统介入;缺点是浪费CPU周期。
  • 阻塞:线程主动放弃CPU,让操作系统调度其他就绪线程。优点是在多任务系统中更友好;缺点是涉及操作系统,开销巨大(数千时钟周期)。

在高度并行、计算密集型的应用中,如果没有其他有用工作可做,忙等待因其低开销而常被采用。


简单自旋锁及其问题

最简单的锁实现是忙等待自旋锁。

初始尝试(非原子操作)

lock: load   reg, [mem]   # 从内存地址 mem 加载值到寄存器 reg
       cmp    reg, #0      # 比较该值是否为0
       jne    lock         # 如果不为0(锁已被持有),跳回 lock 继续循环
       store  [mem], #1    # 如果为0,将内存位置设置为1(获取锁)
unlock: store [mem], #0    # 释放锁,将内存位置设置为0

问题loadcmpstore不是原子操作。在测试为0之后、写入1之前,其他线程可能介入并同样看到锁可用,导致多个线程同时进入临界区,破坏互斥性。


原子操作:Test-and-Set

大多数处理器提供了专门的原子指令来解决上述问题,最经典的是Test-and-Set指令。

Test-and-Set指令语义:原子地读取内存位置的值,如果该值为0,则将其设置为1。无论结果如何,指令都会返回读取到的原始值。

使用Test-and-Set实现锁

lock: tns   reg, [mem]   # 原子地 Test-and-Set
       cmp   reg, #0      # 检查返回的原始值是否为0
       jne   lock         # 如果不是0,说明锁已被他人获取,继续循环
                         # 如果是0,说明本线程成功获取锁
unlock: store [mem], #0   # 释放锁

优点:保证了互斥性。
缺点(性能):考虑缓存一致性协议。当锁被持有时,所有其他尝试获取锁的线程都会不断执行tns指令。每个tns都被视为写操作,需要独占缓存行,导致大量无效化消息和总线/网络流量,即使锁持有者还在临界区内。释放锁时,也会引发所有等待线程的激烈竞争。

总结:Test-and-Set锁简单,无竞争时延迟低,但高竞争下会产生大量一致性流量,且无法保证公平性(可能饿死某些线程)。


改进:Test-and-Test-and-Set 锁

为了减少锁持有期间的总线流量,我们引入 Test-and-Test-and-Set 锁。

实现

lock: load   reg, [mem]   # 1. 普通加载(只读)
       cmp    reg, #0      # 2. 检查是否为0
       jne    lock         #    如果不为0,回到第1步循环(忙等待)
       tns    reg2, [mem]  # 3. 看到为0后,尝试原子Test-and-Set
       cmp    reg2, #0     # 4. 检查是否成功
       jne    lock         #    如果失败(与他人竞争失败),回到第1步
unlock: store [mem], #0    # 释放锁

工作原理:在锁被持有时,所有等待线程在外部循环(第1-2步)进行只读测试。这允许它们以共享状态缓存锁变量,不会产生无效化流量。只有当某个线程读发现锁为0(可能刚被释放)时,它才会进入内部的tns尝试获取。这大大减少了锁持有期间的一致性流量。

优点:显著减少了高竞争下的无效化流量。
缺点:释放锁时,所有等待线程的缓存副本都会失效,并同时尝试tns,仍可能引发一阵流量风暴。依然不公平。


指数退避策略

为了缓解释放锁时的竞争,可以在竞争失败后引入延迟,即退避策略。一个简单的方法是让延迟时间随失败次数指数增长。

示例(伪代码)

int delay = 1;
while (test_and_set(&lock) != 0) { // 尝试获取锁
    for (int i = 0; i < delay; i++); // 忙等待延迟
    delay *= 2; // 指数增加延迟
}
// 进入临界区...
lock = 0; // 释放锁

注意:简单的指数退避可能导致延迟过长。更健壮的实现会结合随机性,并定期重置延迟。

作用:通过让线程“错峰”竞争,减少了多个线程同时冲击原子指令的概率,从而降低了一致性流量峰值。


公平的锁机制

之前的锁都不保证公平性。下面介绍两种提供公平性的锁。


票号锁

票号锁模拟了银行或面包店的取号系统。

数据结构

  • next_ticket:下一个可用的票号。
  • now_serving:当前正在服务的票号。

算法

  • 获取锁
    1. my_ticket = atomic_increment(&next_ticket) (原子获取自己的票号)
    2. while (now_serving != my_ticket) {} (忙等待直到叫到自己的号)
  • 释放锁
    1. now_serving++ (叫下一个号)

工作原理:线程按申请顺序(票号)获得服务。释放锁只需递增now_serving,所有等待线程都会读取到这个新值,但只有票号匹配的那个线程能退出循环。

缓存性能:在等待期间,线程只读now_serving变量,共享缓存行,无额外流量。释放锁时,一次写操作会使所有等待线程的缓存失效,然后它们重新加载新值。流量可控且可预测。

优点:公平、实现简单、缓存友好。
缺点:需要两个整数变量。


数组锁(队列锁)

数组锁是票号锁的一种空间换时间的变体,能进一步减少释放锁时的流量。

数据结构:一个布尔型数组flags[P]P是最大线程数。每个元素通常独占一个缓存行以避免伪共享。另有一个共享变量next

算法

  • 获取锁
    1. my_slot = atomic_increment(&next) % P (原子获取数组中的一个槽位索引)
    2. while (flags[my_slot] != 1) {} (忙等待自己槽位的标志变为1)
  • 释放锁
    1. flags[my_slot] = 0 (清除自己的标志)
    2. flags[(my_slot + 1) % P] = 1 (通知下一个槽位的线程)

工作原理:线程在属于自己的数组槽位上等待。释放锁的线程直接“唤醒”队列中的下一个线程。这形成了一个隐式的等待队列。

缓存性能:在理想情况下,每个等待线程只访问自己独有的缓存行(其flags[my_slot])。释放锁时,写操作只涉及两个缓存行:清除自己的标志和设置下一个线程的标志。这可以产生接近O(1)的一致性流量,尤其是在目录式缓存中。

优点:公平、释放锁时流量低。
缺点:需要预先知道最大线程数P,并分配O(P)的空间。


屏障同步

屏障用于确保所有线程都到达程序中的某个点后,才能继续执行。这在并行计算中非常常见。


简单计数器屏障(有问题)

一个直观的想法是使用一个共享计数器。

有问题的实现

// 共享变量
int arrived = 0;
int flag = 0;
lock_t lock;

void barrier() {
    lock(&lock);
    int my_arrived = ++arrived; // 计数,并获取本地副本
    unlock(&lock);

    if (my_arrived == P) { // 我是最后一个到达的
        arrived = 0; // 重置计数器,为下一轮屏障准备
        flag = 1;    // 升起旗帜,放行
    } else {
        while (flag == 0) {} // 等待旗帜升起
    }
}

问题:当最后一个线程重置arrived=0并设置flag=1后,快速线程可能立即开始下一轮计算,并再次进入barrier(),将arrived从0递增。而此时,上一轮的一些慢线程可能还在读取旧的flag值(例如,由于缓存未更新或延迟),导致它们看到flag=1而通过屏障,但它们的arrived增量会计入下一轮的计数器,造成混乱。


两阶段屏障

正确的屏障需要确保所有线程都离开当前屏障后,才能开始新一轮的屏障。常用方法是两阶段屏障

实现之一(使用离开计数器)

// 共享变量
int arrived = 0, leave = 0;
int flag = 0;
lock_t lock;

void barrier() {
    // 第一阶段:到达
    lock(&lock);
    if (++arrived == P) { // 最后一个到达者
        flag = 1; // 允许其他线程进入离开阶段
    }
    unlock(&lock);

    while (flag == 0) {} // 等待进入离开阶段的信号

    // 第二阶段:离开
    lock(&lock);
    if (++leave == P) { // 最后一个离开者
        arrived = 0; // 重置,为下一轮准备
        leave = 0;
        flag = 0; // 重置标志,为下一轮准备
    }
    unlock(&lock);

    while (flag == 1) {} // 等待所有线程离开,下一轮可以开始
}

工作原理:设置两个计数器。arrived计数到达的线程,leave计数确认可以离开的线程。只有所有线程都到达后(arrived == P),才允许它们进入离开阶段(flag=1)。只有所有线程都离开后(leave == P),才重置状态,开启下一轮屏障。这防止了快线程“套圈”慢线程。

性能:每个屏障涉及O(P)次的锁操作和共享变量访问,在大型系统上可能成为瓶颈。


树形屏障

对于大规模并行系统(P很大),可以使用树形屏障来减少串行化部分。

思想:将线程组织成树状结构(例如二叉树)。线程在叶子节点上,它们首先与兄弟节点同步,然后父节点再与它的兄弟节点同步,依此类推,直到根节点。然后,释放信号再从根节点向下传播。

优点:将全局的O(P)操作减少到O(log P)步。
缺点:每一步的同步可能比简单的原子递增开销稍大,存在常数因子。但当P很大时,对数级缩放优势明显。

这种思想同样适用于MPI等基于消息传递的屏障。


总结与展望

本节课我们一起学习了同步原语的实现与性能考量。

我们首先分析了简单的忙等待锁及其问题,然后介绍了利用原子指令(如Test-and-Set)实现互斥。为了优化性能,我们探讨了Test-and-Test-and-Set锁和退避策略来减少高竞争下的缓存一致性流量。接着,我们学习了保证公平性的票号锁数组锁,它们通过排队机制避免了线程饿死。

对于屏障同步,我们指出了简单计数器实现的问题,并介绍了正确的两阶段屏障原理。对于大规模系统,树形屏障可以提供更好的可扩展性。

这些同步机制在设计和选择时,需要仔细权衡无竞争时的延迟高竞争下的可扩展性公平性以及对缓存/内存系统产生的流量

在后续课程中,我们将探讨另一种同步范式:无锁编程事务内存。这些技术更侧重于优化无竞争或低竞争场景,通过“先执行,后检测冲突”的方式,为程序员提供更灵活、更高效的同步抽象。


下节课预告:我们将深入探讨事务内存的概念、硬件支持及其编程模型。

24:无锁编程与并发数据结构

在本节课中,我们将要学习如何在不使用传统“大锁”的情况下实现共享数据结构。我们将探讨细粒度锁、原子操作以及无锁编程的核心概念,这些技术对于构建高性能的并行程序至关重要。

细粒度锁与原子操作

上一节我们介绍了传统锁的局限性。本节中我们来看看如何通过更精细的控制来提升并发性能。

一种改进方法是使用细粒度锁,即为数据结构的各个部分(而非整个结构)分别加锁。另一种更极端的思路是彻底摒弃锁,使用其他机制来确保共享数据结构的正确更新,这就是无锁编程。

无锁编程是一个重要的研究领域,也是本课程项目的一个有趣方向。例如,仔细实现和评估各种并发数据结构就很有价值。

原子操作:硬件基础

像Pthread互斥锁这样的传统锁是重量级的,涉及操作系统交互,可能导致线程挂起和唤醒,耗费数万个时钟周期。对于只需短暂更新以防止冲突的并发程序,自旋锁通常是更合适的选择。

然而,即使使用自旋锁,如果锁住整个数据结构(如哈希表),也会序列化所有访问,使其不适合并行编程。

因此,许多更精细的方法利用了原子操作。以下是CUDA(以及GCC)中可用的一些原子操作示例:

  • 原子加法/递增:以原子方式执行简单的算术运算。
  • 比较并交换:这是硬件实现的核心基础操作。

让我们重点看看比较并交换操作。

compare_and_swap(addr, expected, new_val) 是一个三参数操作:

  1. addr:目标内存地址。
  2. expected:预期该地址当前存储的值。
  3. new_val:希望存入该地址的新值。

该操作原子性地执行以下步骤:

  1. 读取地址 addr 的当前值,记为 old
  2. 如果 old 等于 expected,则将 new_val 写入 addr
  3. 返回 old 值。

假设我们通过设计确保 new_valexpected 不同(否则操作无意义)。如果返回的 old 值与传入的 expected 不匹配,则说明操作失败,未能成功将 new_val 存入目标地址。

使用比较并交换的示例

以下是使用比较并交换实现原子最小值操作的示例模式(注意,此代码存在潜在无限循环问题,仅用于展示模式):

// 警告:此代码在X不小于当前最小值时会无限循环,仅作模式演示。
void atomic_min(int* addr, int x) {
    while (1) {
        int old = *addr; // 非原子读取,仅用于演示逻辑
        int new_val = (x < old) ? x : old;
        // 假设 compare_and_swap 返回旧值
        if (compare_and_swap(addr, old, new_val) == old) {
            break; // 成功更新
        }
        // 否则重试
    }
}

更合理的例子是实现原子递增或一个简单的自旋锁。例如,实现一个锁:假设锁变量为1表示可用,0表示被占用。获取锁的线程会反复尝试用0与锁变量进行“比较并交换”(预期值为1),直到成功为止。

在C++11及更高版本中,std::atomic 模板类型可以将任何类型变为原子类型。对于基本数据类型(如 int),编译器会使用底层的原子指令(如fetch-and-add)来实现。这是一个相对于C语言编程的巨大优势。

原子操作的硬件实现

像比较并交换这样的底层原子操作是如何实现的呢?在x86架构中,可以在某些指令前添加一个前缀字节(LOCK),指示该指令必须以原子方式执行。硬件通过缓存协议技巧来保证这一点。

在基于总线的缓存系统中,它可以保证在执行原子指令期间,相应的缓存行不会被驱逐,从而防止了“读后无效”的竞态条件。在基于目录的缓存系统中,实现更为复杂,通常需要通过目录来保持对该内存位置的锁定,直到执行处理器释放它。

任何能锁定总线的操作都可能引发棘手的错误。历史上,x86就曾存在一个与 LOCK 前缀相关的漏洞,恶意代码可利用它导致系统挂起。这提醒我们,处理并发问题时,不仅要考虑正常行为,还要考虑攻击者可能选择的病态行为。

并发链表与细粒度锁

比较并交换通常适用于对单个字(word)进行原子操作。但在实际中,我们常需要操作更大的数据结构。例如,在15-418课程作业(代理缓存)中,最简单的实现是为整个缓存加一把大锁(互斥锁或读写锁)。更高级的做法是对缓存中的条目(如页面或链表节点)进行单独加锁,这就是细粒度实现。

让我们以一个标准有序链表的插入和删除操作为例。在并发环境下,多个线程同时插入或删除可能导致竞态条件,例如两个线程同时尝试更新同一个前驱节点的 next 指针,导致一个插入丢失。

最简单的解决方案是为整个链表加一把大锁,但这会使其完全串行化,丧失并发性。

手递手锁

以下是改进方法之一:手递手锁

其核心思想是,线程在遍历链表时,总是持有至少一个锁。它会在获取下一个节点的锁之后,才释放当前节点的锁。这类似于爬绳时“手递手”前进,保证始终至少有一只手抓住绳子。

这种方法保证了操作的某种序列化:所有操作都必须从链表头开始,按相同顺序向下进行,防止了线程“超车”造成的混乱。同时,它确保在修改某个节点时,不仅会阻塞后续操作,也会等待前面的操作完成,从而安全地进行删除或插入。

以下是手递手锁的示例代码框架(注意,此代码可能未处理向空链表插入等边界情况):

typedef struct node_t {
    int key;
    struct node_t *next;
    pthread_mutex_t lock; // 每个节点都有自己的锁
} node_t;

![](https://github.com/OpenDocCN/dsai-notes-pt2-zh/raw/master/docs/cmu-15418-prll-comp/img/ec1a57a553ce63e75a2210a2f8430d3a_4.png)

![](https://github.com/OpenDocCN/dsai-notes-pt2-zh/raw/master/docs/cmu-15418-prll-comp/img/ec1a57a553ce63e75a2210a2f8430d3a_6.png)

typedef struct list_t {
    node_t *head;
    pthread_mutex_t lock; // 链表本身的锁,用于控制初始入口
} list_t;

void list_insert(list_t *L, int key) {
    node_t *new_node = malloc(sizeof(node_t));
    new_node->key = key;
    pthread_mutex_init(&new_node->lock, NULL);

    pthread_mutex_lock(&L->lock); // 获取链表锁
    node_t *prev = NULL;
    node_t *curr = L->head;

    if (curr) pthread_mutex_lock(&curr->lock); // 获取第一个节点锁
    pthread_mutex_unlock(&L->lock); // 释放链表锁,允许其他线程进入

    // 遍历查找插入位置,保持“手递手”
    while (curr && curr->key < key) {
        if (prev) pthread_mutex_unlock(&prev->lock);
        prev = curr;
        curr = curr->next;
        if (curr) pthread_mutex_lock(&curr->lock);
    }

    // 执行插入
    new_node->next = curr;
    if (prev) prev->next = new_node;
    else L->head = new_node;

    // 释放锁
    if (prev) pthread_mutex_unlock(&prev->lock);
    if (curr) pthread_mutex_unlock(&curr->lock);
}

手递手锁是一种有趣的策略,但它通常只适用于有唯一起点、能产生序列化效果的数据结构(如链表、二叉树)。使用像Pthread互斥锁这样的重量级锁来实现手递手锁效率很低,因为锁操作开销太大。如果所有线程都活跃工作,使用自旋锁是更可行的方案。

细粒度锁(包括手递手锁)的主要问题是代码复杂,容易引入难以察觉的bug,并且如果使用传统锁,开销可能仍然很高。

无锁编程

那么,能否完全摒弃锁呢?无锁算法保证至少有一个线程总能取得进展,不会因为某个线程被操作系统换出或崩溃而导致整个系统停滞。虽然在高竞争下,线程可能在循环中不断重试,但概率上它比锁更不易受系统调度影响。

单生产者单消费者队列

一个简单的无锁例子是单生产者单消费者环形缓冲区队列。只要内存读写是原子的(即不会读到半更新的字),并且只有单个线程写队尾指针、单个线程写队头指针,那么此队列无需任何同步原语即可工作。这是因为不存在“写-写”或“读-写”冲突到同一变量。

无锁链表:ABA问题

对于多线程环境,我们可以尝试用“比较并交换”循环来实现无锁栈(链表)。基本思路是:推送时,读取当前栈顶,设置新节点的 next 指针指向它,然后尝试用“比较并交换”将栈顶指针更新为新节点。弹出时类似。

然而,这里存在一个经典问题:ABA问题

假设线程0准备弹出节点A,它读取到栈顶为A,并计算出新的栈顶应为 A->next(即B)。但在执行“比较并交换”之前,线程1完成了以下操作:

  1. 成功弹出A。
  2. 弹出B。
  3. 又将节点A(可能是内存回收后重用的)推入栈顶。

此时,线程0执行“比较并交换”:它发现栈顶仍然是A(尽管是重用的),于是操作“成功”,将栈顶设置为B。这导致B之后的所有节点丢失,且重用的A节点可能包含不一致的数据。

解决ABA问题

解决ABA问题主要有两种思路:

  1. 使用双字比较并交换:将栈顶指针与一个全局的“修改计数器”绑定在一起,作为一个双字(例如,在64位机器上使用128位操作)进行原子交换。这样,即使地址相同,计数器值也不同,从而检测到中间状态的变化。x86架构支持16字节的 CMPXCHG16B 指令来实现此功能。
  2. 危险指针:每个线程拥有一个“危险指针”寄存器。当线程要访问一个节点时,先将该节点的地址存入其危险指针。任何线程在释放(free)一个节点前,必须检查所有线程的危险指针,如果该节点被任何危险指针引用,则不能立即释放,必须推迟。这保证了正在被引用的节点不会被回收和重用。

无锁编程的代码非常精妙且容易出错,但它能在高并发、低竞争的场景下提供优异的性能,并且避免了线程阻塞/唤醒的开销和死锁风险。

性能考量与总结

本节课中我们一起学习了实现并发数据结构的多种技术。

实验结果表明,不同技术在不同场景下表现各异:

  • 低竞争:细粒度锁(尤其是使用重量级锁时)的开销可能比一把大锁还高。
  • 高竞争:细粒度锁由于减少了串行化,性能可能优于大锁。
  • 无锁数据结构:在低到中度竞争下通常表现良好,并且随着线程数增加,其可扩展性往往更好。

在选择技术时,需要考虑应用场景:

  • 如果线程数不超过核心数,且所有线程都保持活跃,那么基于自旋锁的细粒度锁可能是好选择。
  • 对于像Web服务器这样可能拥有大量线程(远超核心数)的应用,线程可能被换出,此时无锁数据结构更具优势,因为它们不依赖于线程的持续执行。

无锁编程是一个深入且有趣的领域,涉及精巧的算法和对硬件内存模型的深刻理解。它不仅是学术研究的热点,也是构建高性能并发系统的重要工具。


总结:本节课我们探讨了超越粗粒度锁的并发数据结构实现。我们从原子操作(如比较并交换)出发,介绍了细粒度锁(如手递手锁)的概念与实现。接着,我们深入研究了无锁编程,分析了其动机、简单案例(单生产者单消费者队列),以及核心挑战ABA问题及其解决方案(双字CAS、危险指针)。最后,我们对比了不同技术的性能特点,并指出其适用场景。掌握这些知识对于编写高效、健壮的并行程序至关重要。

25:事务内存

在本节课中,我们将学习一种更高级的同步抽象概念——事务内存。我们将探讨其核心思想、优势、实现方法,并与之前学习的低级同步原语进行对比。

上一节我们介绍了无锁队列的实现以及ABA问题的解决方案。本节中,我们来看看一种旨在简化并发编程的更高层次抽象。

事务内存的动机

传统的基于锁的同步方法存在诸多挑战:

  • 编程复杂且易错:使用compare-and-swap等低级原语构建无锁数据结构非常繁琐且风险高,在不同硬件平台上可能出现意外行为。
  • 可组合性差:当操作涉及多个锁时,容易引发死锁,程序员必须精心设计锁的获取顺序。
  • 缺乏原子性与隔离性:简单的锁无法保证复杂操作的“全有或全无”(原子性),也无法保证外部观察者看不到操作的中间状态(隔离性)。

事务内存(Transactional Memory)的理念借鉴自数据库领域,旨在让程序员以声明式的方式指定代码块应原子执行,而由系统(编译器、运行时、硬件)负责处理底层的同步细节。

事务内存期望提供以下ACID特性(源自数据库):

  • 原子性(Atomicity):事务中的所有操作要么全部完成,要么全部不发生。
  • 一致性(Consistency):事务执行前后,系统都处于一致状态。
  • 隔离性(Isolation):并发执行的事务彼此隔离,一个事务的中间状态对其他事务不可见。
  • 持久性(Durability):已提交的事务结果是永久性的(在内存系统中通常隐含此特性)。

事务内存的优势示例

考虑一个哈希表与平衡二叉树的并发操作性能对比研究:

  • 粗粒度锁:锁住整个数据结构,严重限制了并发性。
  • 细粒度锁(如为每个链表节点或树节点加锁):提升了并发性,但在树结构中,访问不同分支的线程仍可能在根节点附近发生串行化。

事务内存方案允许线程乐观地执行操作,仅在提交时检测冲突。对于二叉树,即使多个线程访问不同叶子节点,只要它们的读写集不冲突,就可以并发提交,从而避免了根节点的瓶颈。

实现事务内存的关键机制

实现事务内存主要需要解决两个问题:数据版本管理冲突检测

数据版本管理

数据版本管理决定了如何暂存事务中的写操作,以便在需要中止时能够回滚。主要有两种策略:

以下是两种主要的数据版本管理策略:

  1. 积极更新(Eager / Undo-Logging)
    • 做法:立即将新值写入内存,同时将旧值记录在撤销日志中。
    • 提交:成功则丢弃日志。
    • 中止:遍历撤销日志,将内存值恢复为旧值。
    • 挑战:需要额外机制保证隔离性,因为新值在提交前就已可见。

  1. 延迟更新(Lazy / Deferred-Update)
    • 做法:将写操作缓存在一个写缓冲区中,不立即修改实际内存。
    • 提交:将缓冲区中的所有更新一次性应用到内存。
    • 中止:直接丢弃写缓冲区。
    • 优势:天然提供了隔离性,因为实际内存直到提交前都未改变。
    • 挑战:提交过程需要原子性地完成所有更新,可能更复杂。

冲突检测

冲突检测决定何时允许事务提交或必须中止。也有两种主要哲学:

以下是两种冲突检测策略:

  1. 悲观检测

    • 理念:假定冲突很可能发生,在事务执行过程中持续监控读写集。
    • 做法:一旦检测到潜在冲突(如其他事务写入了本事务读过的数据),立即采取行动(如中止或暂停当前事务)。
    • 优势:可以更早地中止注定失败的事务,避免无用功;有时可通过暂停而非中止来解决冲突。
    • 劣势:实现逻辑复杂,可能引入活锁(两个事务相互导致对方重启)。
  2. 乐观检测

    • 理念:假定冲突很少发生,允许事务不受干扰地执行完毕。
    • 做法:在提交时刻,检查事务的读写集是否与在此期间提交的其他事务的写集冲突。
    • 优势:实现相对简单,尤其在硬件中易于实现。
    • 劣势:可能做了很多工作后才发现冲突并中止,造成浪费;策略相对保守。

硬件事务内存(HTM)实践

现代处理器(如某些Intel CPU)提供了硬件事务内存支持。它通常利用现有的缓存一致性协议来跟踪读写集(以缓存行为粒度)。

基本工作流程

  1. 事务开始时,处理器记录寄存器状态,并开始监控特定缓存行的访问。
  2. 执行期间,加载操作会标记缓存行为“已读”,存储操作会标记缓存行为“已写”(值可能暂存在本地缓存,未全局可见)。
  3. 如果监控的缓存行被其他核心无效化(表明被修改),则检测到冲突,当前事务中止。
  4. 提交时,尝试将本地缓存的写操作原子性地变为全局可见。如果成功,则事务提交;如果失败(如由于冲突),则事务中止。

Intel TSX 实现示例
Intel 提供了类似 XBEGINXENDXABORT 的指令来支持HTM。程序员需要提供事务代码段和一个回退代码路径(通常是用传统锁实现的相同逻辑),以备事务多次失败后使用。

硬件事务内存的局限性

  • 容量有限:受限于CPU缓存的结构和大小,能够跟踪的读写集大小是有限的。
  • 指令限制:某些指令(如I/O操作)不能在事务内执行。
  • 始终需要回退路径:由于可能因各种原因(冲突、容量溢出、不支持指令)导致事务中止,程序员必须编写备用的同步方案。

软件事务内存(STM)

软件事务内存完全在软件层(库或运行时系统)实现事务语义。它更灵活,不受硬件限制,但开销通常比HTM高。STM可以实现更复杂的冲突检测和版本管理策略,并且通常以对象为粒度进行管理。

本节课中我们一起学习了事务内存的概念,它是一种旨在简化并发编程的高级同步抽象。我们了解了其相对于传统锁的优势(如原子性、隔离性、避免死锁),并探讨了实现事务内存的核心机制:数据版本管理(积极vs延迟更新)和冲突检测(悲观vs乐观)。最后,我们简要介绍了硬件事务内存(HTM)的基本原理和实践中的局限性。事务内存虽然尚未成为主流编程模型,但它为解决并发编程的复杂性提供了一个有前景的方向。

26:异构计算与硬件加速

在本节课中,我们将探讨异构计算环境带来的优势,以及从纯软件映射到更接近硬件的设计所带来的好处。我们将看到,从手机到超级计算机,都混合使用了不同类型的计算引擎,而功耗已成为当今计算机设计中的核心制约因素。

上一节我们讨论了并行计算的基本模型,本节中我们来看看当计算资源固定时,如何在“少量强大核心”与“大量简单核心”之间做出权衡。

核心数量与性能的权衡

假设我们拥有固定的芯片面积。我们的选择是:制造少量性能强大的“胖核心”,还是大量性能较弱的“瘦核心”。这并非简单的等量交换,因为根据阿姆达尔定律,并行加速受限于程序中无法并行化的串行部分。

阿姆达尔定律的公式可以描述为:

Speedup = 1 / [(1 - F) + (F / N)]

其中:

  • F 是程序中可被并行化的部分比例。
  • N 是并行处理器的数量。

这个公式表明,即使并行部分(F)很大,但只要存在一小部分串行代码(1-F),它就会成为整体性能提升的瓶颈。

为了更具体地分析,我们引入一个资源模型。假设芯片总资源为 N(以基础小核心为单位),每个核心分配的资源为 R,则核心数量为 N / R。单个核心的性能是其资源 R 的函数,我们假设为 perf(R),例如 perf(R) = sqrt(R),表示随着资源增加,性能提升存在边际递减效应。

以下是不同并行化程度(F值)下,整体性能随核心资源配置(R值)变化的分析:

  • 高并行度(如 F=0.9999):最佳策略是使用大量小核心(R值小),以获得接近线性的性能提升。
  • 中等并行度(如 F=0.9):存在一个最优的 R 值,使得整体性能最佳,这可能意味着核心规模需要比基础核心更大一些。
  • 低并行度(如 F=0.5):性能提升有限,即使增加核心数量或增大单个核心,收益也不明显。

这些曲线最终都收敛于一个点:即整个芯片只做一个超大核心(R = N)时的性能。这个模型表明,如果应用程序的并行化程度不高,无论是使用少量大核心还是大量小核心,都难以获得理想的性能提升。

异构核心架构的优势

上述分析引出一个问题:我们是否可以做得更好?与其使用同构的核心,不如采用异构设计:即一个强大的“胖核心”配合许多简单的“瘦核心”。

在这种架构下,我们可以尝试将程序的串行部分映射到胖核心上执行,而将并行部分分发到大量瘦核心上执行。数学模型显示,这种架构在面对不完美的并行化(即存在串行部分)时,性能下降曲线更为平缓。

例如,对于99%并行化的程序,异构架构能在很宽的资源配置范围内保持接近理想的性能。即使对于存在少量(如2.5%)串行代码的程序,性能也能维持在一个较高的水平。这表明,将一部分芯片面积专用于串行处理,同时用大量小核心处理并行任务,是一个有效的设计思路。

当然,这个模型基于一个乐观的假设:我们能完美地将串行计算映射到胖核心上。在实际工程中,这极具挑战性,因为工作负载特征复杂且多变。

现实世界中的异构计算

异构计算的思想已在当前各类计算设备中广泛应用。

个人计算机与服务器:现代处理器(如Intel Skylake)在同一芯片上集成了通用CPU核心、GPU图形处理单元以及媒体处理引擎。集成GPU可以共享最后一级缓存,实现与内存系统的高性能紧密耦合,避免了独立显卡需要通过总线拷贝数据带来的性能损失。

移动设备:手机和处理器(如Apple A系列、NVIDIA Tegra)是“片上系统”(SoC)的典型代表。它们集成了多核CPU、GPU以及众多专用硬件加速器(如视频编解码器、图像信号处理器、数字信号处理器)。苹果等公司通过自研芯片,可以协同设计硬件与手机功能,实现能效和性能的优化。

超级计算机:世界顶尖的超算系统普遍采用CPU+加速器的异构模式。例如,美国的“泰坦”超级计算机在原有CPU集群中加入了NVIDIA GPU,获得了10倍的性能提升。中国的“神威·太湖之光”则使用了自主研发的众核处理器。功耗是制约超算规模的关键因素,而加速器通常能提供更高的每瓦特性能。

硬件加速与能效

推动异构计算和硬件加速的一个根本动力是功耗。无论是在需要限制电费和数据中心散热的大型超算,还是在追求续航和手持温度的移动设备中,功耗都是核心约束。

对典型程序运行的功耗分析显示:

  • 约25%的功耗用于时钟和电路基础开销。
  • 约28%的功耗用于在处理器、缓存和内存之间移动数据。
  • 约42%的功耗用于指令获取、解码、分支预测等控制逻辑。
  • 只有相对较小的一部分功耗真正用于执行应用程序的“有用工作”。

因此,如果能减少控制逻辑和数据移动的开销,让硬件更专注于计算本身,就能显著提升能效。

以下是不同硬件平台执行FFT计算的能效对比:

  • 专用集成电路(ASIC):性能和能效最佳,但完全不可编程,设计成本高昂。
  • 现场可编程门阵列(FPGA):性能和能效介于ASIC和GPU之间,具有一定可编程性,但编程难度高。
  • 图形处理器(GPU):相比CPU有显著提升,尤其适合并行计算任务。
  • 通用处理器(CPU):灵活性强,但能效最低。

硬件加速在移动设备上效果显著。例如,手机视频播放续航长,是因为采用了专用的H.264硬件解码器,其功耗远低于软件解码。数码相机能快速进行JPEG压缩,也是依靠专用硬件。手机中的图像处理功能(如全景拼接、防抖、人像模式)也越来越依赖专用的图像信号处理器(ISP)或AI加速单元。

极端案例如D. E. Shaw Research设计的“Anton”超级计算机,它完全专用于分子动力学模拟,在特定领域比通用超算快数千倍,是专用硬件能力的极致体现。

挑战与未来方向

尽管异构计算优势明显,但也带来巨大挑战:

  1. 编程复杂性:为CPU、GPU、DSP、FPGA等不同架构编写和优化程序是截然不同的体验。将应用迁移到不同硬件平台需要大量工作。
  2. 软件维护:为多种硬件目标维护同一软件功能,增加了开发、测试和调试的复杂性。
  3. 负载均衡与资源匹配:如果某个专用单元(如纹理单元)成为瓶颈,其他强大的计算单元(如SIMD单元)就会闲置,造成资源浪费。

此外,数据移动的能耗已成为关键问题。研究表明,从DRAM中读取数据的能耗,比执行一次浮点运算高出数个数量级。因此,未来的优化方向包括:

  • 通过算法设计减少数据移动,使计算更贴近数据。
  • 利用数据压缩技术,用计算开销换取通信开销的降低。
  • 随着某些计算模式(如加密)的标准化,将其以专用指令或硬件单元的形式集成到通用处理器中。

本节课中我们一起学习了异构计算的基本理念、其在从手机到超算等各种设备中的应用,以及硬件加速带来的显著能效提升。我们也探讨了由此带来的编程和系统设计挑战。在功耗成为核心制约因素的今天,如何智能地混合使用不同类型的计算单元,并高效地将计算任务映射到它们之上,是计算机系统架构中一个非常活跃且至关重要的领域。

27:领域特定编程系统 🚀

在本节课中,我们将探讨如何通过领域特定编程语言(DSL)来应对并行编程的复杂性,实现高生产力和高性能。我们将重点介绍两个案例:用于科学计算的Liszt和用于图像处理的Halide。

项目概述与时间安排 📅

本节课开始时,我们首先讨论了课程项目。项目占总成绩的25%,相当于两个重要作业的工作量。项目主题非常开放,旨在激发你的兴趣,让你在时间限制内尽可能深入地探索。

项目截止日期安排紧凑,旨在确保持续进展。关键节点包括提案检查点、详细提案、进展报告、最终书面报告以及海报展示会。所有截止日期都是严格的,没有宽限日。

项目主题选择 💡

以下是项目主题通常可以归类的几个方向:

  • 应用导向型:选择一个你个人或专业上感兴趣的计算密集型应用(如计算机视觉、计算摄影),并探索如何使其在并行机器上高效运行。需要注意的是,重点应放在并行计算部分,而非应用本身的实现细节。
  • 系统研究型:专注于并行系统的某个方面,例如比较不同的同步原语(如事务内存)、研究Intel的硬件支持,或者为高级语言(如Java、Python)开发并行计算扩展。
  • 平台评估型:评估特定应用在不同平台(如GPU vs CPU)上的性能,或者研究使用Go等高级语言进行并行编程能达到的性能极限。

你可以从零开始编写代码,也可以基于现有专家编写的软件包进行深入研究。如果使用现有代码,我们期望你能进行更深入的测量、实验、调优和优化,探索不同实现之间的权衡。

可用资源与注意事项 ⚙️

在硬件方面,你可以使用课程提供的GPU、GHC集群、Zion高性能节点,甚至可以探索手机、平板(如ARM GPU)或树莓派上的并行计算。如果需要,我们也可以提供Amazon Web Services的预算支持。软件方面没有特定限制,你可以选择任何编程语言和可用软件包。

需要谨慎考虑的项目方向包括FPGA编程,除非你已有丰富经验,否则在有限时间内完成一个有意义的项目可能非常困难。

并行编程的挑战与机遇 🔄

上一节我们介绍了项目相关事项,本节中我们来看看当前并行编程领域面临的挑战。为了获得最佳性能,程序员通常需要在多个层次进行优化:从利用CPU的向量指令(SIMD)和多线程,到协调多核及多服务器间的任务。此外,异构系统(如CPU+GPU)的出现使得软件迁移和优化变得更加复杂。

不同的编程模型(如SIMD、OpenMP、MPI)对应不同的机器抽象,它们之间通常不兼容,迁移程序可能意味着重大重写。这导致了软件开发和维护的巨大负担。

领域特定语言的愿景 🎯

那么,我们能否做得更好?能否找到一种方法,使编写的代码不仅能高效运行在今天,也能适应明天的机器,而无需进行彻底的堆栈重写?

这引出了我们对软件的三点期望:通用性(能编写各种程序)、高性能高开发效率。然而,通常很难同时满足这三点。例如,C/C++通用且性能高,但开发效率较低;Python等解释型语言通用且开发效率高,但性能损失巨大。

领域特定语言(DSL)的策略是:牺牲部分通用性,专注于特定应用领域,以同时获得高开发效率和高性能。SQL数据库查询语言就是一个成功的DSL例子。用户编写高级查询,查询优化器则负责将其高效地映射到具体硬件上执行。

案例一:用于科学计算的Liszt 🔬

现在,让我们深入第一个案例:Liszt。这是一种专为求解偏微分方程(PDE)等网格类问题设计的语言。这类问题通常涉及空间和时间的连续域,需要通过离散化(将空间划分为网格,时间划分为步长)来求解。

Liszt的核心抽象是(或网格)。它允许你定义图结构(顶点和边),并在其上定义场量(如温度、热通量)。然后,你可以编写类似以下的代码来描述计算:

// 示例:基于温度差计算热通量(概念性代码)
for each edge e in mesh:
    v1 = head(e)
    v2 = tail(e)
    dist = distance(position(v1), position(v2))
    temp_diff = temperature(v1) - temperature(v2)
    flux(v1) += temp_diff / dist
    flux(v2) -= temp_diff / dist

这段代码遍历所有边,根据相连顶点的温度差和距离更新顶点的热通量。Liszt采用单次赋值和已知的归约操作,使得编译器能够进行依赖分析,理解数据读写模式。

从同一描述生成不同并行代码 🛠️

Liszt的强大之处在于,它能从同一高级描述自动生成针对不同并行架构的优化代码。

1. 面向分布式集群(MPI风格)的映射:
编译器会分析网格和数据依赖,自动进行空间分区,将网格划分为子区域分配给不同处理器。它会计算所需的幽灵单元(边界副本),并自动生成处理器间交换这些边界数据的通信代码。即使对于不规则网格,这个过程也能自动化完成。

2. 面向GPU的映射:
在GPU上,我们可能为每条边分配一个线程来计算通量更新。但多个边可能写入同一个顶点,造成写冲突。Liszt编译器会构建一个冲突图,并通过图着色算法进行调度。相同颜色的边(不冲突)可以并行执行,不同颜色的边顺序执行。这样就将潜在的原子操作冲突转化为有序的高效执行。

案例二:用于图像处理的Halide 📸

接下来,我们看看第二个案例:Halide。这是一个专门为图像处理设计的领域特定语言,已被Google等公司用于生产环境。

图像处理中常见的一种操作是卷积(或滤波),例如对每个像素及其邻域进行加权平均以实现模糊效果。一个简单的3x3平均模糊的串行C代码可能如下:

for (int y = 1; y < height-1; y++) {
    for (int x = 1; x < width-1; x++) {
        output[y][x] = (input[y-1][x-1] + input[y-1][x] + ... + input[y+1][x+1]) / 9;
    }
}

直接计算需要每个像素进行9次乘加操作(对于n x n核是n²次)。一个优化技巧是分离卷积:先进行水平方向的1D平均,再进行垂直方向的1D平均。这能将计算量从n²降至2n,但需要引入一个与图像等大的中间缓冲区,可能影响缓存效率。

Halide的高层抽象与调度分离 ✨

Halide的关键创新在于将算法描述(要计算什么)和调度策略(如何计算,包括并行、分块、向量化)清晰地分离开。

在Halide中,上述模糊操作可以非常简洁地描述:

# 算法描述:要计算什么
blur_x = Halide.Func()
blur_y = Halide.Func()
x, y = Halide.Var(), Halide.Var()
blur_x[x, y] = (input[x-1, y] + input[x, y] + input[x+1, y]) / 3  # 水平模糊
blur_y[x, y] = (blur_x[x, y-1] + blur_x[x, y] + blur_x[x, y+1]) / 3 # 垂直模糊

然后,你可以独立地指定调度策略来优化性能:

# 调度策略:如何计算以优化性能
blur_y.split(y, yo, yi, 32).parallel(yo).vectorize(x, 8)
blur_x.compute_at(blur_y, yi).store_root().vectorize(x, 8)

这段调度代码指示编译器:在y维度上分块(块大小32)并进行并行化;在x维度上进行向量化(8宽);并指定中间结果blur_x在计算blur_y的每个内循环块时局部计算和存储,以提升缓存局部性。

自动调度与性能 🏆

更令人兴奋的是,基于Halide框架的研究(如CMU与Google的合作)已经能够开发自动调度器。给定算法描述后,自动调度器可以搜索巨大的调度策略空间,找到接近甚至超越人类专家手工调优性能的方案。实验表明,自动调度器在多数情况下能击败花费数小时进行手动调优的Halide专家。

总结 📝

本节课中我们一起学习了领域特定编程系统如何应对现代并行计算的挑战。

  • 我们首先了解了课程项目的开放性、主题选择方向以及严格的时间安排。
  • 接着,我们探讨了当前并行编程在多层次优化和异构系统下面临的复杂性,以及在不同编程模型间迁移软件的困难。
  • 我们引入了领域特定语言的概念,其核心思想是通过牺牲通用性,在特定应用领域内同时追求高开发效率高性能
  • 我们深入研究了两个典型案例:
    • Liszt:用于科学计算网格问题。它提供基于图的高层抽象,允许编译器自动进行依赖分析,并从同一描述生成面向分布式内存集群(使用分区和幽灵单元)或GPU(使用图着色解决写冲突)的高效并行代码。
    • Halide:用于图像处理。其关键优势在于算法与调度分离。用户用高级语言描述计算内容,然后可以独立指定或由自动调度器优化计算策略(如并行、分块、向量化),从而在复杂硬件上实现极致性能。

这些例子展示了通过提升抽象层次、赋予编译器更多领域知识,可以显著简化并行编程,并自动生成适应不同架构的高性能代码,代表了并行编程未来发展的重要方向之一。

28:图分析系统 🚀

在本节课中,我们将继续探讨领域特定系统,但重点转向一类更常见的实现形式:领域特定编程系统或软件包。这些系统不发明全新的语言,而是通过库、运行时系统和编译器支持,在现有语言(如C++)中提供特定领域的强大抽象和优化能力。我们将以图分析为例,深入探讨这类系统的设计理念、实现机制以及如何通过数据压缩和存储优化来提升性能。

上一节我们讨论了领域特定语言(DSL),它们为特定任务类别设计了全新的编程语言。本节中,我们来看看另一类更广泛存在的解决方案:领域特定编程系统。

图分析的重要性与应用 📈

图分析在21世纪初开始兴起,其核心在于认识到许多复杂关系可以抽象为图结构,分析这些图的结构本身就能带来巨大价值。

  • 网页排名:谷歌的创始人发现,通过分析网页(节点)和超链接(边)构成的图,可以推断出哪些页面内容最具权威性,从而诞生了PageRank算法。
  • 社交网络分析:在Facebook的好友关系或Twitter的关注者网络中,分析整体结构可以洞察行为模式、社区发现等。
  • 广泛的应用:许多公司基于图分析构建了核心业务,这不仅是计算机科学的热点,也催生了社会科学研究的新方法。

面临的挑战在于,这些图可能极其庞大,包含数百万甚至数十亿的节点和边,将其映射到标准处理器上非常困难。此外,我们也不希望为每种算法都从头实现底层逻辑。因此,图分析是构建领域特定系统的绝佳领域。

领域特定系统的目标 ⚖️

我们再次回顾性能、完备性和生产力这三个维度。像C/C++这样的通用语言完备性高,通过努力也能获得高性能,但生产力较低。领域特定系统的目标是在这个三角形中找到一个更优的平衡点,即在特定领域内,通过提供高级抽象来兼顾生产力和性能

关键在于,系统需要能够融入该领域专家已知的性能优化技巧。这可以通过两种方式实现:

  1. 系统自身极其智能,能自动完成优化。
  2. 系统为用户提供足够的“钩子”或控制杆,让开发者能够进行调优,而无需重写底层代码。

我们之前在讨论Halide图像处理库时已经见过类似思路:它提供了一个独立的语言来描述如何将图像处理流水线分解、分块以及映射到向量单元上,让程序员能以高级指令的方式完成底层优化。

PageRank算法示例 🔍

PageRank是图分析中的一个经典算法,其核心思想是:一个网页的重要性由其入链(指向它的链接)的质量决定。这类似于“名人效应”——一个人因为认识其他名人而变得出名。

算法公式化表示如下:

PR(p) = (1 - α) / N + α * Σ_{q∈In(p)} (PR(q) / OutDeg(q))

其中:

  • PR(p) 是页面p的PageRank值。
  • α 是阻尼因子(通常为0.85),确保算法稳定收敛。
  • N 是图中总节点数。
  • In(p) 是指向页面p的页面集合。
  • OutDeg(q) 是页面q的出链数量。

该算法是一个迭代过程,每个节点的值基于其邻居节点的值进行更新,直到所有值收敛。一个重要的特性是,在温和的假设下,无论更新顺序如何,它都会收敛到唯一值,这为实现提供了灵活性。

GraphLab:一个图分析编程系统 🛠️

GraphLab是一个源自CMU的项目,它提供了一个在C++中嵌入的框架,用于描述PageRank这类图算法。其设计初衷是为了将大规模图计算映射到由数百或数千台机器组成的集群上。

以下是GraphLab的核心抽象和工作原理:

计算抽象

系统将图视为包含状态(节点和边上的数据)的全局对象。计算由顶点程序定义,每个顶点程序只能访问其“作用域”内的信息,即该顶点、其边及其邻居的数据。

编程模型

用户编写C++函数来描述在每个顶点上执行的单步计算。例如,PageRank的顶点更新函数可以简洁地写成:

void pagerankUpdate(Vertex& vertex) {
    double sum = 0;
    for (Edge& edge : vertex.inEdges()) {
        sum += edge.source().rank / edge.source().outDegree();
    }
    vertex.rank = (1 - ALPHA) / totalVertices + ALPHA * sum;
}

系统提供了遍历邻居、获取度数等原语。

执行引擎

系统根据用户编写的顶点程序,自动生成用于 Gather(从边和邻居收集信息)、Apply(应用更新)和 Scatter(将信息散射到邻边)的代码。一个调度器负责管理顶点操作的执行顺序。

顶点程序可以通过signal函数通知调度器,当前顶点或邻居顶点需要被重新调度(例如,当其值变化超过阈值时)。这实现了基于变化的异步更新传播,直到整个图收敛。

可配置的策略

GraphLab提供了多种执行策略供用户选择,以适应不同算法的需求:

  • 一致性模型:例如,是否允许同时对同一顶点进行读写。
  • 调度策略
    • 同步:像BSP模型,所有顶点基于上一轮状态并行更新,然后同步。实现简单,但收敛可能较慢。
    • 图着色:保证相邻顶点不同时更新,避免冲突。
    • 动态异步:由signal驱动,只更新发生变化的顶点及其受影响邻居,通常收敛更快。

这种将调度、一致性等关注点分离并作为可配置选项的设计,使得系统在保持核心抽象简单的同时,又能提供足够的灵活性和性能优化空间。

性能挑战与优化策略 ⚡

图算法通常对硬件不友好:计算密度低(算术强度低),数据访问模式不规则,内存带宽常常成为瓶颈。针对这些挑战,出现了以下优化思路:

单机与集群的权衡

早期认为处理十亿级边的大图必须使用大型集群(如Hadoop)。但GraphChi项目提出了相反思路:通过巧妙的磁盘存储和访问调度,在单台机器(甚至是一台Mac mini)上处理大规模图。

流式处理与分片(Sharding)

对于无法完全装入内存的图,可以采用流式处理模型。GraphChi的核心思想是将图分片

  1. 将节点划分为多个区间(分片)。
  2. 每个分片存储属于该区间节点的所有入边,并按源节点排序。
  3. 处理时,依次将每个分片(及其关联的边数据)作为“滑动窗口”载入内存。
  4. 由于边已排序,从其他分片读取所需数据时也是连续的,从而最大化I/O效率。

这种方法能以有限的内存,通过多次顺序磁盘访问来处理远超内存容量的大图。

数据压缩

由于图算法常受内存带宽限制,且计算资源相对空闲,可以考虑使用轻量级数据压缩来减少数据体积,即使需要额外的解压缩计算也是划算的。

Ligra系统采用了一种基于差分编码游程编码的压缩方法:

  1. 排序与差分:首先将每个顶点的邻居列表排序,然后不存储邻居ID的绝对值,而是存储相邻ID之间的差值。对于许多现实世界的图(如社交网络、网页链接),这些差值通常很小。
  2. 字节压缩:识别出差值可以用1字节、2字节或4字节表示的连续区间,并用标记位指示区间长度和编码方式。这样,一个长的邻居列表就被压缩成一系列带标记的字节块。

例如,对于顶点ID 32,其排序后的邻居列表为 [5, 6, 10, 15, 100]。存储时先存第一个邻居的相对偏移 5-32 = -27,然后存后续差值 [1, 4, 5, 85]。系统会尝试用最少的字节来编码这些数字。

这种压缩在数据加载时进行轻量解码,显著减少了存储占用和I/O流量,尤其适用于图结构相对静态、可以预处理的场景。

总结 📝

本节课中我们一起学习了领域特定编程系统在图分析领域的应用。我们了解到:

  1. 图分析是一个重要且广泛的应用领域,其核心是对关系进行抽象和迭代计算。
  2. GraphLab 等系统通过提供以顶点为中心的高级编程抽象,将程序员从复杂的并行调度和一致性管理中解放出来,提升了开发生产力。
  3. 实现高性能的关键在于提供可配置的优化策略(如调度和一致性模型),让领域专家能够引导系统获得接近手写优化代码的性能。
  4. 针对图计算固有的性能挑战(不规则访问、低算术强度),可以通过创新的存储布局(如GraphChi的分片流式处理)和轻量级数据压缩(如Ligra的差分游程编码)来显著提升单机或集群的处理能力。

这些案例表明,成功的领域特定系统不仅在于发明一个优雅的抽象,更在于让该抽象能够高效地映射到底层硬件,并通过灵活的设计容纳各种性能优化技巧。

29:深度神经网络与并行计算 🧠

在本节课中,我们将探讨并行计算与深度神经网络之间的关系。深度神经网络已经彻底改变了人工智能和机器学习领域,其影响范围广泛,从图像识别到语音处理和语言翻译。我们将首先了解神经网络的基础知识,然后分别讨论其评估(推理)和训练过程,并分析其中涉及的并行计算挑战与优化策略。

神经网络基础 🧱

上一节我们概述了课程主题,本节中我们来看看神经网络的基本计算单元。神经网络的基本思想源于对计算网络的思考,其核心计算单元执行加权求和运算。

一个基本单元接收一些实数值输入和对应的实数值权重,计算加权和并加上一个偏置项,然后对其应用一个非线性函数。过去人们设计了各种复杂的非线性函数,但近年来,一个极其简单的函数变得非常有效,即 ReLU(Rectified Linear Unit) 函数,其公式为:

output = max(0, input)

这个函数是分段线性的:如果输入值为正,则输出等于输入值;如果输入值为负,则输出为零。它受到生物神经元工作方式的启发,但并非精确模拟。

神经网络的两种模式:评估与训练 ⚙️

神经网络的应用主要分为两种模式。

评估模式:在网络训练完成后,我们反复使用它进行预测或分类。例如,将训练好的翻译模型用于实时翻译。这个过程计算量相对较小,但可能需要在手机等资源受限的设备上高效运行。

训练模式:这是确定网络所有权重值的过程。它需要海量的计算资源和数据,可能在数据中心耗费数天甚至数周的时间。训练过程远比评估过程计算密集。

接下来,我们将首先聚焦于评估过程,然后再讨论训练。

网络结构与卷积层 🕸️

神经网络由这些计算单元(神经元)按一定结构连接而成。输入(如图像)经过网络处理,最终输出分类结果。输入和输出之间的神经元称为“隐藏层”。

网络的结构设计目前仍包含一定的经验成分。一种常见的连接方式是全连接层,即某一层的所有神经元都与下一层的所有神经元相连。但这会带来巨大的计算和存储开销。

另一种更高效的结构是卷积层,它大量应用于图像处理。卷积操作类似于我们之前学过的图像滤波,但权重可以是任意值,而非均匀权重。关键特性是,相同的权重集合(卷积核)会应用于输入数据(如图像)的每一个位置

卷积神经网络因其权重共享的特性,所需的参数量更少,从而更易于训练、存储和评估。有时,我们还会通过池化操作(如最大池化)来降低数据维度,仅保留最重要的特征信息。

以下是卷积层计算的一个简化代码表示,展示了其嵌套循环结构:

for (int image = 0; image < batch_size; ++image) {
  for (int out_channel = 0; out_channel < num_filters; ++out_channel) {
    for (int in_channel = 0; in_channel < input_depth; ++in_channel) {
      for (int y = 0; y < output_height; ++y) {
        for (int x = 0; x < output_width; ++x) {
          for (int fy = 0; fy < filter_height; ++fy) {
            for (int fx = 0; fx < filter_width; ++fx) {
              // 计算加权和
              output[image][out_channel][y][x] +=
                weights[out_channel][in_channel][fy][fx] *
                input[image][in_channel][y+fy][x+fx];
            }
          }
        }
      }
    }
  }
}

循环的嵌套顺序会影响数据的局部性和缓存性能,因此优化循环结构至关重要。

评估阶段的优化策略 🚀

在资源受限的环境(如手机)中进行神经网络评估时,我们需要优化其性能和能效。一个主要挑战是权重数据的存储和访问开销巨大。

为了解决这个问题,可以采用以下几种数据压缩和优化策略:

  • 剪枝:将绝对值足够小的权重直接设置为零,从而减少计算和存储。
  • 量化:将权重值聚类到少数几个离散值上。例如,将所有接近2的权重设为2,接近-1的权重设为-1。然后只需存储权重索引和一个小的码表。
  • 编码:进一步使用霍夫曼编码等无损压缩技术,减少存储每个权重所需的平均比特数。

通过结合这些技术,可以在几乎不影响模型精度的情况下,将模型大小压缩数十倍,从而显著降低存储访问能耗。

训练过程与梯度下降 📉

现在,让我们转向计算量更大的训练过程。训练的目标是找到一组权重,使得网络在整个训练集上的预测损失最小化。这是一个涉及数百万甚至数十亿参数的复杂非线性优化问题。

解决此问题的标准方法是随机梯度下降。其核心思想是:

  1. 计算当前权重下,单个训练样本(或一小批样本)的损失函数相对于每个权重的梯度。
  2. 沿着梯度方向,以一个小步长更新所有权重。
  3. 重复此过程,直至损失收敛。

梯度通过反向传播算法计算。它利用链式法则,从输出层开始,逐层向后计算损失对每一层权重的偏导数。需要注意的是,为了进行反向传播,在前向传播过程中产生的所有中间结果都需要被保存下来。

训练阶段的并行化策略 🤖

训练过程在计算和数据量上都极为庞大。我们可以利用以下并行性:

  • 数据并行:这是最主流的方
    法。将训练数据划分到多个处理器(或机器)上。每个处理器用完整的模型权重处理自己的一份数据,独立计算梯度,然后将所有梯度汇总并更新全局权重。这类似于之前作业中的批处理。
  • 模型并行:将模型本身(不同的层或同一层内的不同部分)划分到多个处理器上。这种方法通信开销较大,实现更复杂,通常用于模型过大无法放入单个设备内存的情况。

在实际的大规模分布式训练中(如谷歌的数据中心),常采用异步并行策略。各个计算节点从中央的参数服务器获取当前权重,计算梯度后异步地推送回更新。参数服务器负责整合这些更新。这种方法放松了对同步的要求,容忍节点间的延迟和少量不一致,从而提高了整体吞吐量和系统容错性。

总结与展望 🔮

本节课我们一起学习了深度神经网络与并行计算的紧密联系。

我们首先介绍了神经网络的基本计算单元和结构,重点讲解了高效的卷积层。然后,我们分别探讨了神经网络在评估(推理)训练阶段的挑战。

在评估阶段,我们讨论了通过循环优化、权重剪枝、量化和编码等技术来减少计算量和存储访问,以适应边缘设备。

在训练阶段,我们深入分析了随机梯度下降反向传播的原理。并详细阐述了如何通过数据并行异步参数服务器等策略,在分布式集群上实现大规模并行训练,以处理海量数据和复杂模型。

当前,深度神经网络领域仍在快速发展。硬件方面,出现了专用于矩阵运算的TPU、支持低精度计算的GPU等;软件方面,涌现了TensorFlow、PyTorch、Caffe等高级框架来简化开发。未来,更高效的网络结构、更低的数值精度要求以及新的硬件加速方案将继续推动该领域进步。


版权说明

  • 本教程内容翻译整理自 CMU 15-418/618 课程讲座《Lecture 29: Parallel Computing and Deep Neural Networks》。
  • 原课程版权归卡内基梅隆大学及讲师所有。
  • 字幕来源:GPT中英字幕课程资源 - BV18b421J7cA。
  • 本教程仅为知识分享与学习用途。

30:消息传递与并行运行时实现

在本节课中,我们将学习消息传递(如MPI)和共享内存并行运行时(如OpenMP和Cilk)的内部实现细节。我们将探讨数据如何在处理器间移动、不同的缓冲策略、以及编译器如何将高级并行指令转换为底层的线程操作。


消息传递的实现细节

上一节我们回顾了并行编程的基本模型。本节中,我们来看看消息传递模型在实际系统中是如何实现的,特别是数据移动和缓冲的关键问题。

消息传递的基本通信操作是发送(send)和接收(receive)。发送方执行发送操作,接收方执行接收操作,这有效地将数据从发送方的私有地址空间复制到接收方的私有地址空间。

消息传递系统之所以流行,一个简单原因是它易于构建大规模并行计算机。与共享地址空间不同,它不需要缓存一致性或内存系统的特殊硬件支持。理论上,任何设备(如手机或物联网设备)都可以通过网络互相发送消息,组成一个大型并行计算机。

在并行计算的整个历史中,这都是一种相当流行的构建大型机器的方式。如今在云计算中,云中的许多服务器也以这种方式运行。当你有独立的刀片服务器或节点时,它们之间不一定有缓存一致性,因此使用消息传递是让软件工作的最简单方法。

与共享地址空间的对比

在讨论消息传递之前,我们先简要对比共享地址空间下的数据通信。

在共享地址空间中,数据通信主要通过缓存进行。当读取一个共享地址时,数据会被拉取到处理器的缓存中。因此,数据的传输主要由读取操作触发。

读取操作涉及将虚拟地址转换为物理地址,然后内存系统硬件根据物理地址查找数据。这可能涉及通过互连网络发送请求到主存节点或其他处理器的缓存(脏副本),然后数据返回。这本质上是一个请求-响应协议。

关键点在于:

  • 传输中使用的地址是物理地址,整个内存系统都理解物理地址的含义。
  • 数据到达时,接收方(缓存)已经为其预留了位置。
  • 这个过程不需要在地址空间之外进行额外的缓冲。

相比之下,在消息传递中,存在源地址目标地址两个不同的地址。发送方在发送时并不知道目标地址,甚至接收方在执行接收操作之前也不知道数据应该放在哪里。

同步与异步消息传递

你可能还记得,在消息传递的网格求解器代码示例中,有同步和异步两种版本。

  • 同步消息传递:发送方会一直阻塞,直到接收方完成接收。
  • 异步消息传递:发送方将消息缓冲后立即继续执行,接收方稍后再接收。

从高层次看,异步方式听起来更好,因为它不会阻塞发送方。然而,实现细节带来了挑战。

同步消息传递的实现

以下是实现同步消息传递数据转移的典型方式:

  1. 发送方执行发送操作,但不立即发送实际数据。它先发送一个短消息(通知),告知接收方“我有一个消息要发送给你”,其中包含标签等参数。
  2. 假设接收方已经执行了匹配的接收操作,并准备接收。接收方收到通知后,会回复一个确认消息,其中包含目标地址(数据应存放的位置)。
  3. 发送方收到确认后,知道了目标地址,便可以执行直接内存访问(DMA) 传输,将数据从发送方地址空间的已知位置复制到接收方地址空间的已知位置。

这种方法的优点是,一旦发送方知道目标地址,数据可以直接复制到正确位置,无需在地址空间之外进行额外缓冲。主要缺点是性能:发送方必须阻塞等待,直到接收方准备好。如果接收方尚未执行接收操作,发送方可能被阻塞任意长的时间。

异步消息传递的实现

在异步消息传递中,发送方执行发送后,传输在后台进行,发送方继续执行。

乐观异步方法
发送方立即开始发送实际数据。数据到达接收方时,接收方可能尚未执行接收操作,因此不知道数据应存放在何处。此时,数据必须被放入一个缓冲区。消息传递层必须在系统级别分配缓冲区空间来保存这些消息。当接收方最终执行匹配的接收操作时,它从缓冲区中查找并复制数据到本地地址空间。

这种方法性能上具有吸引力,因为发送方不会被阻塞,并能尽快移动数据。但存在严重问题:消息可能很大,且任意数量的处理器都可能向一个处理器发送大量消息。随着处理器数量增加,缓冲区空间可能无限增长,甚至耗尽内存。

保守异步方法
这种方法结合了同步和异步的特点。发送方执行发送后,立即发送一个短通知消息(而非实际数据),然后继续执行。接收方在准备好接收(即执行了匹配的接收操作)后,发送一个确认消息回发送方,其中包含目标地址。发送方在后台(例如通过事件处理程序)收到此确认后,再进行DMA传输。

这种方法将缓冲问题从接收方转移到了发送方。如果发送方发送了大量消息,而接收方尚未接收,发送方的缓冲可能增长。然而,这通常比接收方缓冲溢出更容易处理,因为发送方本地可以感知缓冲耗尽,并自然地通过阻塞发送方来进行流量控制(流控),这比要求远方的发送方停止发送更简单。

混合方案:基于信用的方法

为了结合乐观方法的速度和保守方法的安全性,可以采用一种基于信用(Credit-Based) 的混合方案。

其思想是:

  1. 接收方为每个发送方预分配一定量的缓冲区空间(例如 4KB)。
  2. 消息传递层会向发送方通告其可用的信用额度(即剩余缓冲区大小)。
  3. 发送方在发送消息时,会检查消息大小是否小于其当前信用额度。
    • 如果,它可以乐观地立即发送数据,因为它知道接收方有空间存放。
    • 如果(信用不足),则必须采用保守的握手方式。
  4. 当接收方消费(处理)消息后,会在反向通信(如发送消息给原发送方时)中“捎带”更新对方的信用额度。

这种方法对程序员是透明的,由消息传递层内部管理。

消息传递实现中的关键问题

实现消息传递层时,有两个根本问题需要关注:

  1. 输入缓冲区溢出:我们已讨论了基于信用的方案。其他不可取的方法包括直接拒绝消息或丢弃数据包,因为这些方法通常无法根本解决问题,并可能导致性能下降或死锁。

  1. 死锁:一个典型场景是两个处理器互相发送大量消息,然后才执行接收操作。它们可能用发送的消息填满彼此的缓冲区,导致双方都无法向前执行到接收操作来清空缓冲区,从而形成死锁。

最流行的解决方案是使用独立的请求和响应网络(通常是逻辑上分离)。例如,将互连带宽的一部分保留给请求,另一部分保留给响应。这样,即使请求因缓冲区满而阻塞,响应仍然可以继续传递,打破死锁循环。

消息传递总结

从程序员接口看,消息传递是数据的单向传输。但这种单向性在确定数据存放位置和避免缓冲区溢出方面带来了问题。

设计实现时需要:

  • 避免需要全局知识的方案,以保证可扩展性。
  • 支持大量并发消息,以实现高性能。
  • 处理输入缓冲问题(如采用信用机制)。
  • 由于消息传递延迟显著,系统喜欢采用推测和其他技巧来与其他操作重叠执行。

共享内存并行运行时:OpenMP与Cilk

上一节我们深入探讨了消息传递的内部机制。本节中,我们将视角转向共享内存并行运行时,特别是OpenMP和Cilk,看看它们如何在底层实现其高级并行抽象。

OpenMP和Cilk都是共享内存编程模型,都采用fork-join模式,并且底层都使用Pthreads来实现并发。它们提供了比直接使用Pthreads更高级、更便捷的并行描述方式,但有时也会带来额外的开销,并且可能无法暴露程序员所知的某些优化信息。

OpenMP的实现

以下是一个简单的OpenMP并行循环代码示例:

#pragma omp parallel for reduction(+:r)
for (i=0; i<10; i++) {
    r = r + a[i];
}

编译器(如LLVM)在遇到-fopenmp标志时,会识别这些编译制导语句并进行代码转换。它并非直接输出上述代码,而是插入额外的变量和函数调用。

转换后的逻辑大致如下:

  1. 并行区域开始:主线程遇到parallel for时,会调用一个__kmpc_fork_call函数,指明一个函数(如main._omp_fn.0)将被并行执行。
  2. 工作函数:原始的for循环体被包装进这个工作函数中。函数内部会处理迭代空间的划分。
  3. 动态调度:如果指定了dynamic调度,运行时库会管理一个任务队列。每个工作线程在执行完一个任务块(chunk)后,会请求下一个任务块。
  4. 归约操作:每个线程拥有本地归约变量副本。循环结束后,这些本地副本的值需要通过原子操作或其他方式合并到全局变量r中。
  5. 隐式屏障:并行区域结束时,所有线程会同步在一个屏障(barrier)上。屏障的实现可能有多种(如线性屏障、树形屏障),由运行时根据系统情况选择。

通过工具可以观测到,默认情况下,运行时创建了与硬件线程数相等的线程(例如8个),但实际执行循环迭代的线程可能只有部分,并且工作负载可能不平衡。

OpenMP其他特性

  • atomic:编译器会根据架构和数据类型,将原子操作转换为相应的硬件指令(如x86的lock add用于整型),或使用更复杂的指令序列(如compare-and-swap循环用于浮点数)。
  • task:任务依赖会创建微任务(microtask),运行时通过跟踪变量的地址和长度来管理依赖关系图,并在前置任务完成后调度后续任务。

Cilk的实现

Cilk采用一种不同的并行范式,基于“spawn”(生成)和“sync”(同步)来创建并行任务。以下是一个经典的Cilk Fibonacci示例:

int fib(int n) {
    if (n < 2) return n;
    int x = cilk_spawn fib(n-1);
    int y = fib(n-2);
    cilk_sync;
    return x + y;
}

Cilk的语义规定:遇到cilk_spawn时,父线程(调用者)继续执行spawn出的子任务(fib(n-1),而被spawn的延续部分(fib(n-2)及其后的代码) 可能被其他工作线程窃取(work-stealing)以并行执行。

Cilk运行时的核心是工作窃取调度器。其实现的一个关键机制是使用C语言的setjmplongjmp 来保存和恢复“延续”(continuation)的上下文(包括栈和寄存器状态)。

编译器会将简单的Cilk代码转换为包含许多基本块(basic block)的复杂控制流图,以处理并行执行、窃取和同步的所有可能路径。例如,一个简单的fib函数可能被转换成数十个基本块,用于管理setjmp/longjmp、标志位检查以及不同执行路径的清理工作。

工作线程(即Pthreads)从一个公共队列中窃取任务。当窃取到一个延续时,它使用longjmp跳转到该延续的上下文中执行。如果无任务可窃取,线程可能在一个信号量上等待。

通过工具同样可以观测Cilk程序的执行,查看线程何时窃取工作、何时同步。对于非常小的计算(如fib(6)),可能因为计算太快而没有发生窃取,程序以串行方式运行。对于更大的计算,则可以观察到线程间的负载均衡和窃取行为。


总结

本节课中,我们一起深入探讨了并行编程中两种核心范式的底层实现。

消息传递部分,我们分析了同步、异步以及混合信用机制的数据传输方式,理解了缓冲管理和死锁避免的挑战。这解释了为何MPI编程中需要注意消息大小和通信模式。

共享内存并行运行时部分,我们剖析了OpenMP和Cilk如何将高级并行指令编译和转换为底层的Pthreads操作和复杂控制流。我们看到了OpenMP如何管理并行循环、任务和原子操作,以及Cilk如何利用工作窃取和上下文切换来实现高效的递归并行。

理解这些底层机制,不仅能帮助你编写更高效、更可靠的并行代码,也为将来进行并行系统研究、优化甚至自己实现运行时库奠定了基础。

posted @ 2026-03-26 01:39  绝不原创的飞龙  阅读(7)  评论(0)    收藏  举报