
多核处理器的出现标志着计算领域的一次根本性转变,它通过将多个处理单元(即“核心”)集成到单个芯片上,承诺带来前所未有的性能。虽然这项创新让我们超越了单核频率缩放的局限,但它也引入了一系列全新且更为复杂的挑战。那种认为“核心加倍,速度加倍”的直观想法,很快就与硬件物理和软件设计的顽固现实发生了碰撞。为什么我们不能简单地启动一千个核心来即时解决任何问题?当这些核心试图协作时,又会出现哪些看不见的瓶颈和悖论?
本文深入探讨了多核计算的复杂世界,以回答这些问题。在第一章 原理与机制 中,我们将探索支配并行性能的基础定律和物理约束,从阿姆达尔定律不容置疑的逻辑到“暗硅”现象的惊人崛起。我们将剖析当多个核心必须协同工作时出现的通信、同步和内存排序等关键问题。在此之后,应用与跨学科联系 章节将把我们的焦点从理论转向实践。我们将看到这些原理如何塑造并行算法的设计,影响操作系统调度器,并推动从科学计算到数据分析等领域的突破,揭示出真正的计算能力不仅在于拥有众多工作者,更在于将他们指挥成一曲完美的交响乐。
要领略多核处理器的奇迹,我们必须超越“越多越好”的简单观念。想象一下,你正在一个厨房里管理着一支由才华横溢的厨师组成的团队。你的第一反应可能是,厨师数量翻倍,你就能准备双倍的餐食。但很快,你就会遇到并行工作中那些微妙而迷人的复杂性。厨师们可能会相互碰撞,他们可能同时都需要同一种稀有食材,或者他们可能会为谁能使用那台唯一的大烤箱而争吵。多核处理器的世界就是这个厨房,只不过被微缩到了硅片上,其原理是在惊人的速度与令人沮ّ丧的瓶颈之间上演的一场优美舞蹈。
多核处理器的宏大承诺是线程级并行——即同时执行不同指令流(或线程)的能力。如果一个任务可以被分成 个独立的部分,那么 个核心难道不应该以 倍的速度完成它吗?这一田园诗般的设想一头撞上了一个简单、优雅却又毫不妥协的原则,即阿姆达尔定律。
阿姆达尔定律提醒我们,几乎每个任务都有一个顽固的串行部分。想想我们的厨师:他们可以同时切蔬菜、调酱汁、准备配菜。这是并行部分。但如果每道菜都必须在唯一的主烤箱里烤20分钟,那么这段烘烤时间就是串行瓶颈。无论你雇佣多少厨师,你都无法缩短那20分钟的烘烤时间。如果一个程序有一半的时间花在串行任务上,那么即使有无限数量的处理器,最多也只能使其速度提高一倍。
但故事变得更有趣了。如果雇佣更多的厨师反而让每个厨师的工作效率都降低了一点呢?这不仅仅是一个古怪的思想实验;它是芯片设计中的一个基本限制。处理器有一个总功耗预算,这是它能消耗多少能量和散发多少热量的上限。每个活动核心都会增加这个总功耗。当你激活越来越多的核心时,你可能会超出这个预算,迫使整个芯片以较低的时钟频率运行,以保持在其热量和功耗限制之内。
这产生了一个有趣的权衡。想象一个处理器,其频率随活动核心数 而降低,或许如 。你程序的并行部分现在因 个核心而得到提升,但又因频率 的降低而受到惩罚。而只在一个核心上运行的串行部分,没有得到并行提升,却同样遭受了频率降低的惩罚!这就导出了一个非凡的结论:对于任何给定串行比例为 的程序,存在一个最优的核心使用数量 。超出这个点再增加核心是适得其反的——频率降低带来的减速超过了更多并行工作者带来的好处。核心的交响乐在此时找到了完美的和谐,不是在最大音量处,而是在一个平衡的节奏上。
我们刚才讨论的功耗预算已经导致了现代计算中最深刻和惊人的现实之一:暗硅。几十年来,工程师们依赖一个名为丹纳德缩放的原则。随着晶体管越来越小,它们的功率密度保持不变。这意味着我们可以在芯片上封装更多的晶体管并以高速运行它们,而不会让芯片熔化。那是一个黄金时代。
那个时代已经结束。随着晶体管缩小到原子尺度,它们变得“漏电”,即使在不主动开关时也会浪费功率。将它们做得更小不再保证功率成比例下降。派对结束了,给我们留下了功耗的后遗症。
今天,我们可以制造出拥有数十亿晶体管的芯片,足以构建数百甚至数千个核心。但我们缺乏足够的功耗预算来同时开启它们。在任何给定时间,大部分硅片必须保持未通电状态,即“暗”状态。这就像拥有一座有一百个房间的豪宅,但断路器只能承受其中十个房间的灯光。
让我们把这个问题具体化。一个核心消耗的功率取决于其电压 和频率 。为了最小化功耗,我们希望以尽可能低的电压 运行。然而,即使在这个最低电压下,每个核心也会消耗一个最小的功率量 。如果你的芯片总功耗上限为 ,那么你任何时候能同时激活的核心数量的绝对最大值是 。如果你制造了一个包含 个核心的芯片,而 ,那么在物理上就不可能同时为它们全部供电。对于一个实际的95瓦功耗上限的芯片来说,这个限制可能在159个核心左右。一个拥有160个核心的芯片将是第一个至少有一个核心必须是暗核的芯片。这就是暗硅时代的黎明,一个根本性的转变,迫使我们不再将多核芯片视为一个整体的资源块,而是一个由可根据需要开关的专用核心和加速器组成的灵活基板。
那么,我们有了一定数量的活动核心。它们如何在一个共享任务上协作?最常见的模型是共享内存,所有核心都可以读写一个公共地址空间。然而,为了速度,每个核心都维护着自己的私有高速记事本,称为缓存。麻烦就从这里开始。
如果核心A在它的私有缓存上写下“x=5”,那么可能持有一条旧笔记“x=1”的核心B如何以及何时得知这一变化?这就是缓存一致性问题。为了解决它,处理器使用了复杂的礼节协议。最常见的是MESI,它代表缓存行可以处于的四种状态:
这个协议是有效的,但“呐喊”的代价可能非常高昂。考虑一个简单的任务:一个许多线程都需要递增的全局计数器。如果计数器存储在单个共享内存位置,每当一个新核心想要递增它时,它都必须“呐喊”:“我需要写入!”这是一个请求所有权读取 (RFO)请求,它会使所有其他副本失效,并强制包含该计数器的缓存行被发送到请求核心。对于由 个不同核心进行的 次递增,你会得到 次昂贵的RFO。
一个更聪明的做法是让每个核心维护自己的私有计数器。每个核心在自己的记事本上“低语”着递增。主线程周期性地过来,从每个核心收集总数并进行汇总。这极大地减少了一致性的“呐喊”。一个简单的模型表明,这种本地计数方法可以将一致性流量减少一个因子 ,其中 是两次聚合之间的本地递增次数。这揭示了一个深刻的原则:在并行编程中,要将你的通信构造成不频繁和批量的,而不是频繁和细粒度的。
协议本身也在演进。MOESI协议增加了一个拥有 (O)状态。这个聪明的补充允许一个核心持有脏(修改过的)副本,同时让其他核心直接读取它,而无需强制先缓慢地写回主内存。这是礼节规则上的一个小改动,但对于某些工作负载,它节省了大量的消息数量,从而节省了能量。
通信最关键的形式之一是同步:确保一次只有一个核心可以进入代码的“临界区”。保护临界区最简单的方法是使用自旋锁。一个想要进入的核心在一个循环中检查一个锁变量。如果锁被占用,它就“自旋”,一遍又一遍地检查。“轮到我了吗?轮到我了吗?”
在现代处理器上,这种孩童般的不耐烦是灾难性的。如果检查涉及写操作尝试(如简单的测试并设置),每次失败的尝试都是一次RFO,它会使所有其他自旋核心缓存中的缓存行失效。结果是一场由无效化消息组成的“一致性风暴”,消耗了宝贵的内存带宽和功耗,却没有任何有用的工作。这就是嘈杂的混乱。解决方案是礼貌:指数退避。在一次失败的尝试后,核心会等待一个随机的时间段再试,每次后续失败都会使潜在的等待时间加倍。这将尝试操作去同步化,平息了风暴。
一种更智能的自旋锁,测试-并-测试-并-设置 (TTAS)锁,首先通过只读取锁变量来进行自旋。读取共享值是廉价的。只有当锁看起来是空闲时,核心才会尝试昂贵的写操作来获取它。即便如此,在高竞争下,许多核心可能会同时看到锁变为空闲并一起冲上去获取它,导致昂贵的写尝试和无效化操作激增 [@problem-id:3645691]。
最终的解决方案通常是在硬件本身中构建更好的工具。比较一个使用原子比较并交换 (CAS)指令的基于软件的计数器,和一个使用专用硬件取并加 (FAA)指令的计数器。在有 个核心的高竞争场景中,CAS循环是悲观的:所有 个核心都读取一个值,但只有一个会成功交换它。其他 次尝试都失败了,白白浪费了去内存控制器的行程。相比之下,FAA指令是乐观的:一个核心发送一个“给这个地址加5”的请求。内存控制器原子地处理该操作。每个被服务的请求都会导致一次成功的递增。分析结果惊人地简单而优美:基于FAA的设计的吞吐量比朴素的CAS循环高出 倍 [@problem-id:3621231]。这证明了专用硬件如何能蒸发掉软件瓶颈。
我们现在来到了多核处理器最微妙、最令人费解的方面。我们喜欢将程序的执行想象成一个单一的、顺序的故事。但为了榨干每一滴性能,现代处理器是臭名昭著的作弊者。它们在幕后重新排序指令。
每个核心都有一个存储缓冲区,这是它打算写入主内存的私有列表。当一个核心执行一条 store 指令时,它只是在缓冲区中草草记下这次写入,然后继续执行下一条指令,也许是从一个完全不同地址的 load 指令。这次存储将在稍后,当核心有空时,才会被刷新到主内存。这使得核心可以避免在等待缓慢的内存操作时发生停顿。
然而,这种重新排序可能导致奇异的结果。考虑这个简单的程序,其中 x 和 y 最初为0:
| 线程 P0 | 线程 P1 |
|---|---|
x := 1 | y := 1 |
r1 := y | r2 := x |
如果两个线程都运行了,我们发现 r1 为0且 r2 为0,这可能吗?这似乎是不可能的!如果 r1 为0,那么P0必须在P1写入 y 之前读取了它。如果 r2 为0,那么P1必须在P0写入 x 之前读取了它。这就产生了一个逻辑上的时间循环:P0的读取发生在P1的写入之前,P1的写入发生在P1的读取之前,P1的读取发生在P0的写入之前,P0的写入又发生在P0的读取之前。
然而,在一个宽松的机器上,这个结果是完全可能的!原因是这样的:P0将 x := 1 放入其存储缓冲区,并立即执行 r1 := y,从主内存中读取到0。同时,P1将 y := 1 放入其存储缓冲区,并立即执行 r2 := x,从主内存中读取到0。两个核心都看到了“旧”值,因为它们各自的写入都还没有变得全局可见。
为了防止这种时间悖论,程序员需要一个特殊的工具:内存栅栏(或内存屏障)。一条栅栏指令是对处理器的直接命令:“停止你所有聪明的重排序。在该栅栏之前的所有内存操作都变得全局可见之前,不要继续执行。”栅栏是一条重量级指令,但当我们的算法依赖于它时,它是我们恢复一个理智的、顺序的世界观的基本工具。这最后一个原则揭示了最深层的权衡:我们可以通过允许处理器扭曲时间规则来获得近乎神奇的性能,但我们必须确切地知道何时以及如何约束它,以确保我们的程序保持正确。
我们已经穿越了支配多核世界的原理,一个并行思维的世界。但这一切究竟是为了什么?这种新架构是否只是像更大的引擎使汽车更快一样,让我们的计算机变得更快?你可能会惊讶地发现,答案是一个响亮的“不”。多核处理器不是一个更大的引擎;它是一种完全不同类型的交通工具。它不像一辆直线加速赛车,而更像一个管弦乐队。一个独奏大师可以演奏得惊人地快,但一个管弦乐队可以创造出一部交响曲。就像管弦乐队一样,多核处理器的力量不是通过简单地告诉每个人都演奏得更快来实现的;它需要一位杰出的指挥家——一个聪明的算法——来协调演奏者,管理他们的互动,并从许多独立的努力中引导出一个美丽、连贯的成果。
本章是关于那种指挥的艺术和科学。我们将看到并行的挑战如何迫使我们以全新的方式看待问题,从安排医院手术到模拟星系的舞蹈。我们将发现,多核计算的原理并不仅限于计算机科学;它们反映了关于组织、瓶颈和效率的基本真理,这些真理在我们周围随处可见。
想象你正在管理一家拥有几间顶级手术室的医院。你需要执行许多手术,但有一个问题:你只有一套特定且不可或缺的机器人手术系统。这个系统是一个共享资源,一次只有一个手术能使用它。手术室是你的“核心”,手术是你的“线程”,而机器人系统是保护“临界区”的“锁”。即使有三间手术室,如果你安排的所有五台手术都同时需要机器人,那么将有四台手术会闲置等待。整个医院的效率突然之间不再取决于房间的数量,而是取决于那台机器人的排队情况。这就是竞争的幽灵,也是并行编程的第一个巨大挑战。
所以,作为管理者,你必须决定顺序。你应该让最长的手术先做,以“把它了结”吗?或者也许是最短的?这不仅仅是一个后勤难题;它是操作系统设计中的一个经典问题。事实证明,如果你的目标是最小化每位患者等待手术完成的平均时间,最优策略被证明是总是安排最短作业优先。通过清理掉快速的任务,你可以减少队列中所有其他任务累积的总等待时间。这个简单而强大的思想,被称为最短作业优先(SJF)调度,是操作系统如何管理对共享资源(从文件到网卡)的竞争的基石之一。
当然,现实世界要混乱得多。一个任务不仅仅需要“时间”;它需要特定数量的内存,一定的网络带宽等等。将任务调度到核心上变成了一个多维度的谜题。这类似于经典的“装箱问题”:你如何将一堆不同大小和重量的物品装入有限数量的箱子里?操作系统调度程序每天都面临这个问题,试图将具有不同内存占用和执行时间需求的人物,装配到具有固定内存和时间预算的核心上。为了解决这个问题,调度程序通常使用聪明的启发式方法,比如“最佳适应递减”——首先处理最大、最耗费资源的任务,并尝试将它们放在能够留下最少剩余、碎片化资源的核心上。这是管理现代计算机复杂资源景观的一种实用、有效的策略。
到目前为止,我们的图景假设任务是独立的,就像一系列不相关的手术。但如果一个任务的输出是另一个任务的输入呢?如果你在建好墙壁之前无法建造屋顶怎么办?世界充满了这样的依赖关系,它们形成了一种无形的编排,我们的并行算法必须遵守。
计算机科学家使用有向无环图 (DAG)来可视化这些关系,其中每个节点是一个任务,从任务A到任务B的每个箭头意味着“A必须在B开始之前完成”。在多核处理器上调度任务就变成了一个遍历这个图的游戏。调度程序可以执行任何其前驱任务都已完成的任务。一个常见而有效的策略是列表调度,我们首先创建一个尊重依赖关系的所有任务的有序列表(一个“拓扑排序”),然后随着核心变为空闲,贪婪地从这个列表中分配任务给它们。
这个概念不仅仅是一个抽象;它是高性能科学计算的日常。考虑解决一个包含数百万个线性方程的系统的艰巨任务,这是从天气预报到飞机设计等一切事物的核心问题。像高斯消元法这样的算法可以被分解成一个复杂的小操作的DAG。这个DAG的结构不是任意的;它就是算法的结构。高性能计算的真正艺术在于重新构造这些经典算法,以创建具有更短“关键路径”(最长的依赖链)和在每个阶段有更多独立任务的DAG,从而暴露出更多可以并行完成的工作。
多核革命最深远的影响在于算法本身的设计。我们不能再仅仅发明一个串行配方,然后希望调度程序能找到一种并行运行它的方法。我们必须问一个更深层次的问题:这个问题本质上是并行的吗?
考虑找到连接一组城市的最便宜道路网络的问题——一个最小生成树 (MST)。一个经典的方法,Prim算法,是根本上串行的:它从一个城市开始,贪婪地一次一条边地扩展网络。这就像从一个单一的种子开始生长晶体。但另一种方法,Borůvka算法,采用了一种奇妙的并行方法。它以每个城市作为其自己的孤立组件开始,并在每个阶段指示每个组件找到其连接到不同组件的最便宜的连接。所有这些连接都同时被添加,合并组件。这就像让几十个晶体同时生长,然后让它们融合在一起。关键在于,在每个阶段,每个组件所做的工作都完全独立于其他组件,使得该算法天然适合并行执行。
这种并行迭代的范式无处不在。想象一下,在一个庞大的社交网络中识别朋友集群(寻找连通分量)。一个优美的并行方法是让每个人都以自己独特的ID作为他们的“集群ID”开始。然后,在同步的回合中,每个人都查看他们直接朋友的集群ID,并采用他们看到的最小ID。这种“八卦”重复进行。关于集群中最小ID的信息像波一样在其中传播,最终,同一集群中的每个人都会就同一个最小ID达成一致。这种迭代的、局部通信的、全局收敛的过程是在并行机器上解决大规模图问题的强大模式 [@problem-id:3223789]。
然而,并行性很少是全有或全无的事情。当我们并行化一个经典算法如build_heap(堆排序的关键部分)时,我们发现可用并行量随着算法的运行而变化。在概念树结构的底层,有许多独立的子问题可以同时处理。但随着我们向根部推进,问题合并,依赖增加,工作变得更加串行。这揭示了阿姆达尔定律的一个实际方面:任何并行算法的加速比最终都受其最串行部分的限制。
这些概念在计算科学的宏大挑战中最为关键,也被最巧妙地结合在一起。考虑一个分子动力学模拟,它通过计算数百万个单个原子的力和运动来模拟材料的行为。这是一个复杂度惊人的问题,解决它需要并行技术的交响乐。
在最精细的层面上,根据作用在每个原子上的力来更新其位置的简单行为是一个数据并行任务。相同的计算被应用于数百万个原子,这是单个核心内SIMD(单指令,多数据)单元的完美工作,它就像一个对整个数据排 barking 单一命令的教官。
在下一个层面上,计算力要困难得多。一个原子上的力取决于其邻居的位置。虽然对每对原子的计算是一个独立的任务,但许多这样的任务需要更新同一个原子上的总力,从而产生竞争条件。这让我们回到了医院的类比:我们需要原子操作或其他同步方案来确保最终的力被正确求和。
在最宏大的尺度上,如果模拟对于一台计算机来说太大了,3D空间被划分为子域,每个子域被分配给集群中的不同计算机。这些机器并行运行,通过消息传递接口(如MPI)定期通信关于其边界附近原子的信息。
这种多层次的方法——同时利用数据并行、任务并行和分布式并行——是我们解决当今最大科学问题的方式。底层数值算法的选择也至关重要。在许多模拟中,需要求解大型线性系统。在这里,像分块Householder QR分解这样的算法比基于Givens旋转的算法要受欢迎得多。这并不是因为一个比另一个“更并行”,而是因为它具有更高的计算强度。它被设计为对从内存中提取的每个字节数据执行许多浮点运算。在现代CPU和GPU上,计算速度快但内存访问慢,这是性能的秘诀。这样的算法就像一个厨师,精心计划以最大限度地减少去食品储藏室的次数,用台面上已有的食材做尽可能多的工作。
最后,这首交响曲的指挥必须着眼于硬件的物理现实。当操作系统调度工作时,它不仅必须考虑速度,还必须考虑功耗。将一个作业分散到许多核心上可能看起来很有效,但这可能需要让它们全部以低效的低频率运行。有时,将任务仅在少数核心上以较高频率运行,并将其他核心置于深度睡眠状态,可能更节能。这个决定涉及在开关晶体管消耗的动态功耗和它们仅仅因为通电而浪费的泄漏功耗之间的微妙权衡。在某些情况下,一个临界的泄漏功耗阈值决定了是整合工作还是分散工作更好。这使我们的旅程回到了起点,从抽象的算法思想回到电子在硅中流动的物理学,提醒我们,在多核处理器的世界里,指挥家不仅要懂音乐,还要懂乐器本身。