try ai
科普
编辑
分享
反馈
  • 并行化原理

并行化原理

SciencePedia玻尔百科
核心要点
  • 并发是在重叠的时间段内管理多个任务,而并行是在不同的硬件单元上同时执行任务。
  • 阿姆达尔定律指出,无论使用多少处理器,程序的最终加速比从根本上受其串行部分的限制。
  • 并行流水线的性能(吞吐量)不是由总工作量决定的,而是由其最慢的单个阶段,即瓶颈决定的。
  • 硬件优化可能导致非直观的内存行为,要求程序员使用内存屏障等特殊指令来保证并行代码的正确性。

引言

在追求更强计算能力的过程中,仅仅提高单个处理器的速度已不再足够。解锁下一层次性能的关键在于并行化——让计算机同时做多件事情。然而,从串行思维到并行思维的转变带来了巨大的挑战,因为我们关于任务如何展开的日常直觉可能具有误导性,并导致对性能和正确性的根本性误解。许多人难以区分并发和并行等关键概念,或无法理解为什么增加更多处理器并不总能带来成比例的加速。

本文为理解这一复杂领域提供了一个清晰的框架。第一部分“​​原理与机制​​”将阐明核心概念,探讨并发与并行的区别、瓶颈和阿姆达尔定律的影响,以及那些使并行编程变得困难的、令人惊讶的硬件行为。随后的“​​应用与跨学科联系​​”部分将展示这些基本原理如何成为从响应迅速的智能手机应用到突破性科学发现等一切事物的驱动力,从而提供一个并行化在实践中应用的广阔视角。

原理与机制

要真正理解让计算机同时做多件事情的威力与风险,我们必须首先成为细致的时间侦探。在一个事件发生于十亿分之一秒内的世界里,我们关于任务按顺序展开的直觉可能是一个很差的向导。我们必须做出的第一个也是最关键的区分,是关于两个看似相似但实则天壤之别的概念:​​并发​​和​​并行​​。

巨大的错觉:并发 vs. 并行

想象一位大厨在厨房里工作。这位厨师忙得像一阵旋风。他先把汤用文火炖着,在汤炖着的时候,他开始为沙拉切蔬菜。警报响了——烤箱里的面包好了。他取出面包,然后回去继续切菜。从外面看,这位厨师似乎在同时做所有事情。但在任何一个瞬间,他只在做一件事:切菜、搅拌,或者从烤箱里取面包。这就是​​并发​​:通过在任务之间智能地切换,在同一时间段内管理多个任务并取得进展。

一台只有一个处理核心的现代计算机就像这位厨师。考虑一个单线程运行的Web服务器。它可能正在为用户A处理文件下载,为用户B等待数据库查询,并为用户C处理登录请求。服务器的事件循环——它的“大脑”——就是这位厨师。当一个任务必须等待某个慢速操作(比如从磁盘读取)时,事件循环不会闲置。它会挂起那个任务,并立即切换到另一个准备好运行的任务。它交错执行这些任务,创造出同时行动的错觉。我们看到所有方面都在取得进展,但用户级代码仍然是一次执行一条指令。这种并发而非并行执行的一个关键线索是非确定性:操作的确切顺序可能每次运行都略有不同,这取决于文件读取何时完成或网络数据包何时到达。

我们甚至可以通过测量在任何给定时间有多少任务处于“进行中”或“未完成”状态,来量化这种“并发级别”。即使只有一个核心在执行代码(并行度为1),被管理的并发任务数量也可能高得多,这反映了系统在巧妙处理其职责方面的效率。

现在,让我们给厨师配个助手。当厨师搅拌汤时,助手可以切蔬菜。他们现在在同一时间处理不同的任务。这就是​​并行​​:在不同的处理单元上同时执行多个任务。这需要多个物理资源——在这个例子中,是两个人。在计算机上,则需要多个处理器核心。

但这里有一个美妙而时而令人沮丧的微妙之处。仅仅拥有多个核心(多个厨师)并不能保证程序会并行运行。想象一下,我们的两位厨师需要使用一把独一_无二的、非常特殊的魔法刀来工作。一次只有一个人能握住这把刀。即使有两位厨师,任何时候也只有一位能进行切割。这正是使用​​全局解释器锁(GIL)​​ 的编程语言(如标准版的Python)所面临的情况。即使你的机器有16个核心,你运行16个线程,如果你的代码是纯粹的Python计算,GIL会确保一次只有一个线程能执行Python字节码。操作系统可能会将这些线程调度到不同的核心上,但它们无法并行取得进展;它们被迫轮流使用解释器。程序表现出并发性——线程是交错执行的——但其主要的计算工作未能实现并行。为了获得真正的并行,可能需要使用独立的进程,每个进程都有自己的解释器和自己的“魔法刀”,而这种策略也有其自身的成本。

这种区别不仅仅是学术上的;它是理解性能的绝对基础。并发是一种构建程序以同时处理多件事情的方式。并行是一种通过在多个硬件单元上同时执行来让程序运行得更快的方式。

速度的承诺:流水线与瓶颈

那么,假设我们有一个可以真正并行运行的任务。这能给我们带来什么好处呢?最优雅的并行工作模型之一是​​流水线​​,就像汽车装配线一样。

想象一个任务被分解为三个阶段:生产者、过滤器和消费者。在单核上,要处理一个项目,计算机必须先完成阶段1的工作,然后是阶段2,最后是阶段3。处理每个项目的总时间就是每个阶段时间的总和。

现在,让我们把它放在一个三核机器上,每个阶段专用一个核心。第一个项目仍然必须按顺序通过所有三个阶段。这被称为​​冷启动延迟​​。但是,当第一个项目从核心1移动到核心2时,核心1现在就可以空出来开始处理第二个项目了。而当第一个项目移动到核心3时,核心2开始处理第二个项目,核心1则开始处理第三个。流水线现在已经满了。从此刻起,一个新的、已完成的项目会以固定的节奏从装配线的末端产出。

是什么决定了这个节奏,或者说​​吞吐量​​呢?不是总时间,也不是平均时间。整个流水线的吞吐量由其最慢的阶段决定。这就是流水线的​​瓶颈​​。如果过滤器阶段处理每个项目需要 8 ms8\,\mathrm{ms}8ms,而生产者需要 5 ms5\,\mathrm{ms}5ms,消费者需要 4 ms4\,\mathrm{ms}4ms,那么整个流水线每 8 ms8\,\mathrm{ms}8ms 只能产出一个完成的项目。较快的阶段只会闲置,等待瓶颈阶段完成。加速生产者或消费者对最终吞吐量没有任何影响。要让流水线更快,你必须加速最慢的部分。这个简单而深刻的思想支配着无数并行系统的性能,从CPU指令流水线到全球数据处理网络。

通用法则:阿姆达尔墙

流水线揭示了一个强大的真理:并行可以极大地提高吞吐量。这引出了一个自然的问题:如果我有一个任务,并且使用越来越多的处理器,我能让它运行得任意快吗?如果我有足够多的核心,我能在一秒钟内完成一个需要一年时间的计算吗?

遗憾的是,答案是否定的。原因在于并行计算中最基本的原则之一:​​阿姆达尔定律​​。

想象任何程序都由两部分组成:一部分是比例为 ppp 的完全可并行的部分,另一部分是比例为 1−p1-p1−p 的顽固的、无法并行化的串行部分。这个串行部分可以是任何东西:从单个文件读取初始数据、在其他任何部分开始前必须运行的一小段逻辑,或者是合并所有并行结果的最后一步。先驱性的计算机架构师 Gene Amdahl 意识到,这个串行部分就像一个锚,束缚了整个程序的性能。

无论你为并行部分投入多少处理器 MMM,将其时间减少到接近于零,串行部分仍然需要相同的时间。你能实现的总加速比 SSS 由这个优雅的公式给出:

S=1(1−p)+pMS = \frac{1}{(1-p) + \frac{p}{M}}S=(1−p)+Mp​1​

假设你的程序有 95%95\%95% 是可并行的(p=0.95p=0.95p=0.95),5%5\%5% 是串行的(1−p=0.051-p=0.051−p=0.05)。即使有无限多的处理器(M→∞M \to \inftyM→∞),pM\frac{p}{M}Mp​ 项趋近于零,加速比 SSS 也只会接近 10.05=20\frac{1}{0.05} = 200.051​=20。无论你购买多少硬件,你的程序速度提升都不会超过20倍。那微小的 5%5\%5% 的串行代码变成了一个不可逾越的速度极限,一堵无形的墙。

更糟糕的是,现实往往比 Amdahl 的理想模型更严酷。有时,并行化任务的行为本身会引入新的串行瓶颈。例如,如果你所有的并行线程都需要定期更新一个受软件锁保护的共享计数器,它们就必须排队等待。这种锁竞争创建了一个新的串行部分,这个部分在单线程版本中甚至不存在,从而进一步限制了你能实现的实际加速比。

并行的多种面貌

到目前为止,我们主要谈论的并行是不同核心做不同的事情。这通常被称为​​线程级并行(TLP)​​。但并行是一个更丰富、更多层次的概念。

在单个CPU核心内部,存在另一种更细粒度的并行形式。想象一位教官,他不是对每个士兵单独下令“向左转”,而是可以大喊一个命令“全排,向左转!”,让每个士兵同时执行相同的指令。这就是​​单指令多数据(SIMD)​​指令背后的思想。现代CPU拥有特殊的硬件,可以接受一条指令(如“加法”),并将其同时应用于一整个向量的数字(比如8个或16个)。这被称为​​数据级并行(DLP)​​。使用这些指令的程序正在利用核心内部的并行性。这意味着一个系统可以同时展现多个层次的并行:不同核心间的TLP,以及每个核心执行流内部的DLP。

我们还可以进一步放大视野。考虑中央处理器(CPU)和图形处理器(GPU)之间的关系。CPU就像一位将军:能力强、聪明,并且擅长复杂的、顺序性的决策。GPU则像一支由数千名简单但速度飞快的士兵(GPU核心或流式多处理器)组成的庞大军队。CPU通过向GPU发出命令(“内核启动”)来卸载一个大规模的数据并行任务,比如渲染图像或训练神经网络。然后,GPU用其数千个核心并行处理不同部分的数据来执行这个命令。

这就创造了一场异构计算的美妙舞蹈。CPU(将军)可以发出一个命令,并且由于启动是异步的,它可以立即将注意力转向其他任务——这是CPU和GPU之间的一种并发形式。与此同时,GPU(军队)以大规模并行的方式执行命令。这种从核心内部的SIMD通道,到芯片上的多个核心,再到像GPU这样的专用加速器的并行层次结构,是现代高性能计算的引擎。

魔鬼在细节中:缓存、一致性与鬼魅般的超距作用

如果并行能提供如此惊人的性能,为什么并行编程又出了名的困难且充满bug呢?原因在于,脱离简单的串行世界迫使我们面对现代硬件实际工作方式中那些奇异且非直观的现实。

考虑两个需要通信的线程。我们应该将它们固定在同一个CPU核心上,还是不同的核心上?答案并不明显。如果它们在同一个核心上,它们必须轮流执行(并发,非并行),但它们之间的通信快如闪电,因为它们共享核心的本地数据缓存。如果它们在不同的核心上,它们可以真正地并行运行,但每当一个线程写入另一个需要读取的数据时,一个名为​​缓存一致性​​的复杂且相对缓慢的硬件协议必须启动,以确保数据在核心之间正确地传递。对于计算量大、通信量小的任务,使用不同核心是胜利的选择。对于通信量大的任务,缓存一致性的开销可能非常高,以至于在单个核心上并发运行它们反而更快。最佳策略取决于工作本身的性质。

这把我们引向了并行编程中最深层、最奇怪的恐怖之处:内存模型。让我们做一个思想实验。两个人,Alice和Bob,在两个独立的隔音房间里。他们每个人面前都有一面最初是放下的旗帜。他们都有相同的指令:“1. 升起你的旗帜。2. 看看对方的旗帜是否升起。” 他们执行这两个步骤。有没有可能Alice看到Bob的旗帜是放下的,同时Bob也看到Alice的旗帜是放下的?

我们的串行直觉会尖叫“不可能!”。他们中的一个肯定先完成了第1步,所以另一个人肯定会看到对方的旗帜已经升起。但在现代多核处理器上,答案是令人震惊的​​“是”​​。

这种情况的发生是因为每个CPU核心都有一个私有的“存储缓冲区”——可以把它想象成一个个人记事本。当Alice的核心执行“升起你的旗帜”(写)指令时,它不会立即将此信息广播到整个系统。它首先将其记在自己的私有存储缓冲区中。核心不想等待,于是立即执行下一条指令:“看Bob的旗帜”(读)。在这一瞬间,来自Bob核心的信息——它也正存放在自己的存储缓冲区中——尚未在芯片上传播开来。因此,Alice的核心读取了旧值,看到Bob的旗帜是放下的。Bob的核心在完全相同的时间做了完全相同的事情。两个线程都观察到了一个从逻辑上讲本不应该发生的状态。

这种“写-读重排”是硬件为单线程性能进行不懈优化的结果。在真正的并行(两个核心带着各自的私有缓冲区同时操作)下,观察到这种情况的可能性远大于在单核上的简单并发。这是一种数据竞争,也是并行程序中最阴险、最难以复现的bug的根源。为了防止这种“鬼魅般的超距作用”,程序员必须使用称为​​内存屏障​​的特殊指令。内存屏障是给处理器的一道命令,它说:“停下!在你确保之前记下的所有写操作都对其他所有核心可见之前,不要进行任何进一步的读操作。”。

这正是并行化的真正挑战与魅力所在。它提供了一条通往几乎无法想象的计算能力的道路,但它要求我们放弃对世界简单的、循序渐进的看法。我们必须成为自己机器的物理学家,理解支配这个由核心组成的复杂、互联的宇宙中关于时间、内存和通信的微妙规则。

应用与跨学科联系

在探讨了支配并行执行的原理之后,我们现在走向外部世界,看看这些思想是如何在现实中应用的。你可能会惊讶地发现,并行和并发的概念并不仅限于超级计算机的深奥领域。事实上,它们是你数字生活的无形建筑师,是现代科学的引擎,也是我们用以构建人工智能未来的工具。我们的旅程将揭示,同样的基本挑战——如何划分工作、管理依赖关系和克服瓶颈——会以智能手机应用和宇宙模拟这样迥然不同的形式出现。这正是一个深刻的物理或计算原理的内在之美:它将看似无关的现象统一在一个单一、优雅的框架之下。

数字心跳:日常计算中的并行

每当你点击、滑动或轻触屏幕时,你都在与建立在并发基础上的系统进行交互。思考一下你手机上一个普通的移动应用程序。当你打开一个应用,进入一个需要加载你的头像、最近消息和新闻列表的屏幕时,它必须从远程服务器获取所有这些信息。如果应用一个接一个地执行这些网络请求,每个请求都有可感知的延迟,那么用户界面就会冻结、卡顿并变得无响应——这是一种令人沮丧的体验。

解决方案在于理解“做工作”和“等待工作完成”之间的区别。一个现代应用程序使用非阻塞操作并发地发起所有网络请求。操作系统处理这些请求,而这些请求大部分时间都在等待数据通过互联网传输。在这段等待期间,应用程序的主用户界面(UI)线程不会被阻塞;它仍然可以自由地执行动画过渡、响应你的触摸,并保持体验的流畅性。这是对​​并发​​的精妙运用:多个任务通过将计算与等待交织在一起,在重叠的时间间隔内取得进展,即使在单个处理器核心上也是如此。如果下载的数据需要大量处理(如解压图像),真正的​​并行​​就可能发挥作用,这些处理可以被卸载到你多核手机上其他可用的CPU核心。现代应用程序设计的首要原则是永远不要阻塞UI线程,而并发是实现这种响应性的主要工具。

同样的原则可以扩展到为整个互联网提供动力。Web服务器是并发处理能力的证明。想象一个服务器是一个包含多个阶段的流水线:它必须解析请求,可能从数据库获取数据(一个I/O密集型任务),对这些数据执行一些计算(一个CPU密集型任务),最后将响应写回客户端。一个简单的单线程服务器一次只能处理一个请求,在开始下一个请求之前,必须让当前请求走完整个流水线。其吞吐量将因所有阶段延迟的总和而严重受限。

然而,一个高性能服务器是一个分阶段的并行流水线。它通过让不同的请求同时处于不同的阶段来处理大量的请求。当一组请求在等待数据库时,另一组正在CPU核心上进行计算,第三组则正在写入网络。像计算这样的CPU密集型阶段直接受益于​​并行​​;增加更多核心可以同时计算更多请求。像数据库查询或远程获取这样的I/O密集型阶段则受益于​​并发​​;系统可以同时有数千个I/O操作“进行中”,重叠它们的等待时间。整个系统的总吞吐量不是由其各部分的总和决定的,而是由其最慢的阶段——​​瓶颈​​决定的。如果网络设备每秒只能处理100个请求,那么无论你增加多少CPU核心,服务器的吞吐量都无法超过这个限制。

这种单一共享资源限制整体性能的概念是普遍存在的。想象一个城市供水网络,其中一个主阀门有最大流量限制。你可以有数百个家庭(任务)同时尝试取水,但每秒输送的总水量是固定的。该系统表现出并发性,但在瓶颈处没有并行。增加并发用户数量只会平分固定的容量,如果每个用户的流量降得太低,可能会降低所有人的服务质量。这个类比突显了服务器设计中两种流行架构之间的根本权衡:事件驱动的单线程模型(就像我们的供水网络,有一个非常快的控制器)和多线程模型(它可以使用多个核心,但会产生管理线程的开销)。前者通过避免上下文切换成本,在单核上处理I/O密集型工作负载方面表现出色;而后者是利用多核硬件的真正并行性来处理CPU密集型任务的唯一途径。

并行的语言:从代码到核心

我们优雅的、顺序性的人类思想,写成一行行代码,是如何转变为一场并行执行的交响乐的?这种转换是计算机科学的巨大挑战之一,落在了编译器的肩上。编译器在自动并行化中的工作不仅仅是机械的。它必须扮演一个语义侦探的角色,在将程序的不同部分分配到不同核心之前,证明它们是真正独立的。

考虑一个处理数组的简单循环。如果每次迭代都只是一个纯粹的计算,仅依赖于自身的数据,那么这个任务就是“易于并行”的。但如果一次迭代有副作用,比如打印到屏幕上呢?语言的语义可能要求输出的顺序与串行循环的顺序相同。一种简单的并行化会导致混乱、杂乱的输出,因为线程会以不可预测的顺序完成。编译器不能简单地忽略print语句;它是程序的一个可观察行为。因此,一个聪明的编译器会转换代码:它允许独立的计算并行运行,但不是直接打印,而是每个线程将其结果(及其原始索引)存储在一个临时缓冲区中。所有线程完成后,一个最终的串行步骤按正确的顺序打印缓冲区中的结果,从而在获得并行计算速度的同时,保留了程序的可观察行为。

为了对这类转换进行形式化推理,计算机科学家们开发了精确的语言。“先行发生”(happens-before)关系是程序中事件的一种偏序关系,它捕捉了本质的依赖关系。像扩展流程图这样的图形表示可以使这些依赖关系显式化。用于fork(将执行拆分为并行分支)和join(等待所有分支完成)的特殊节点,以及用于获取和释放mutex锁的节点,使我们能够为并行程序构建一个健全且完整的蓝图。这个蓝图不仅仅是一张图纸;它是一个可以被分析以保证正确性并防止数据竞争的形式化模型,确保我们的并行交响乐不会退化为噪音。

解锁自然之谜:科学发现中的并行

并行计算的力量在科学计算领域表现得最为淋漓尽致,它让我们能够建立虚拟实验室,模拟从星系碰撞到蛋白质折叠的一切。许多这类模拟都涉及将空间离散化为网格或有限元集合。

在用于设计桥梁和飞机的有限元法(FEM)中,一个物理对象被表示为由较小单元组成的网格。全局结构的属性源于这些单个单元的贡献。为了计算全局刚度矩阵,每个并行进程为其元素子集计算局部矩阵,然后将它们加到一个共享的全局矩阵中。这是一个经典的“分散相加”操作。如果两个进程试图同时向全局矩阵的同一条目添加值,就会发生数据竞争。解决方案是使用​​原子加法​​,这是一种保证读-改-写周期不可分割的操作,确保每个贡献都被正确计算在内。另一种称为图着色的策略,涉及以同步波的方式处理不相邻的元素,从而巧妙地完全避免写冲突。这些“分散”(粒子到网格)和“收集”(网格到粒子)的模式是许多基于粒子的模拟方法的计算核心,比如用于在电影中创造出极其逼真的雪和水动画的物质点法(MPM)。

科学算法的自身结构决定了其并行潜力。以共轭梯度(CG)法为例,这是一种用于求解这些模拟中产生的大型线性方程组的主力方法。CG的单次迭代涉及多种操作。一些操作,如向量更新和稀疏矩阵向量乘积(SpMV),是高度数据并行的:输出的每个元素都可以独立计算。然而,其他操作可能是内在地串行的。某些强大的预处理技术,如不完全Cholesky分解,涉及求解三角系统。这会产生一长串的依赖关系——要计算第 iii 个结果,你必须先知道第 (i−1)(i-1)(i−1) 个——这从根本上抗拒并行化。这揭示了一个深刻的真理:我们不能简单地对任何问题都投入更多核心。算法本身必须具备并行性。

同样的主题在计算生物学中也有体现。多序列比对(MSA)任务通过比较不同物种的DNA或蛋白质序列,对于理解进化关系至关重要。一个典型的MSA流水线是一个多阶段的工作流,每个阶段具有不同的并行特性。初始步骤,即计算 NNN 个序列之间的所有两两比对,是易于并行的——所有的 (N2)\binom{N}{2}(2N​) 个比对都是独立的任务。用于每次比对的核心动态规划算法本身可以使用GPU上的“波前”模式进行并行化。然而,从这些两两比对的距离构建“指导树”的步骤通常是串行的,因为每个合并决策都依赖于上一个。最后,重建比对路径的回溯步骤也是顽固的串行。因此,一个成功的并行实现必须是混合式的,对问题的不同部分应用不同的并行策略,在粗粒度并行、细粒度数据并行和不可避免的串行步骤之间编排一场复杂的舞蹈。

教机器学得更快

人工智能的革命是由海量数据集和海量计算推动的。并行是在合理时间内训练当今复杂机器学习模型的唯一方法。例如,随机森林是一种由许多单个决策树的集成构建的强大模型。在数据的随机样本上训练每棵树都是一个独立的任务。这使得该算法天然适合​​树级并行​​:如果你有 kkk 个核心,你就可以同时训练 kkk 棵树。

但我们可以更聪明一些。在训练单棵树的过程中,在每个节点,算法都会搜索用于分裂数据的最佳特征。这个搜索过程也可以并行化。我们可以使用多个核心来并发地评估不同的特征,这是一种​​特征级并行​​的形式。最佳策略可能是一种混合方法:也许我们分配几个核心来加速每棵树的构建,然后并行构建几棵这样的树。最佳选择取决于数据和硬件的具体情况,涉及到并行执行带来的加速与同步和调度开销成本之间的权衡。

贯穿始终的主线

从我们手机屏幕的流畅响应性到探索宇宙的宏大科学模拟,并行是贯穿始终的主线。它是管理复杂性和克服计算物理限制的一项基本策略。这段旅程向我们展示了其核心思想是普适的:识别独立工作,小心管理依赖关系,并时刻留意瓶颈。无论我们是确保正确输出的编译器工程师,是对齐基因组的生物信息学家,还是为新材料建模的物理学家,我们都在问同一个根本问题:我们如何将这个问题分解,以便更快地、共同地解决它?这个问题的答案将继续定义未来计算和发现的前沿。