try ai
科普
编辑
分享
反馈
  • 共享内存多处理器

共享内存多处理器

SciencePedia玻尔百科
关键要点
  • 共享内存系统营造了单一内存空间的假象,但这引入了缓存一致性问题,该问题通过MESI和MOESI等硬件协议得以解决。
  • 从少数核心扩展到大量核心,需要从基于广播的监听协议转向可扩展的基于目录的一致性协议,这催生了非一致性内存访问(NUMA)架构。
  • 为追求性能,现代处理器采用宽松的内存一致性模型,这迫使程序员使用诸如释放-获取语义之类的同步原语来保证正确的程序顺序。
  • 像比较并交换(CAS)这样的原子操作是无锁数据结构的基本构建模块,但需要谨慎处理ABA问题等。

引言

在对计算能力的不懈追求中,重点已从提升单个处理器的速度转向协调多个处理器协同工作。在并行计算中,最基本、最直观的范式之一是共享内存多处理器,这是一种多个处理器访问公共内存池的架构。该模型有望在协作方面实现无与伦比的速度和简单性,但在这优雅的抽象之下,却隐藏着一系列深刻的工程挑战。共享工作空间的简单愿景与分布式缓存和互连的物理现实之间的鸿沟,必须通过系统堆栈每一层级的巧妙解决方案来弥合。

本文深入探讨了共享内存系统的核心原理和实际应用。在第一章“原理与机制”中,我们将探索创建统一内存视图的基础性挑战,从使用MESI等协议解决缓存一致性问题,到可扩展性限制所催生的基于目录的系统和非一致性内存访问(NUMA)拓扑。我们还将揭示内存一致性模型的微妙规则,以及构成所有并发编程基石的原子硬件指令。随后,“应用与跨学科联系”一章将展示这些原理的实际应用,阐明它们如何支持复杂的同步算法、高效的操作系统设计以及现代GPU的大规模并行处理。通过从硬件逻辑到高级软件的探索之旅,我们将对这些强大机器的构建和编程方式获得一个全面的理解。

原理与机制

想象一组才华横溢的工匠共同创作一件巨大的雕塑。他们协作最自然的方式就是围在雕塑周围,每个人都能看到整体,并能触摸和修改任何部分。这就是​​共享内存多处理​​的美好而简单的愿景:多个独立的处理器或核心,都在一个共享内存中保存的公共数据池上工作。这是一种统一的架构,数据不为任何单个处理器所有,而是所有处理器的全局资源。这种紧密的连接使得极其快速和细粒度的协作成为可能。

另一种选择是分布式系统的世界,工匠们各自在独立的作坊里工作。为了协作,他们必须来回发送详细的消息和蓝图。虽然这允许巨大的规模,但通信缓慢而繁琐。像商定下一处凿痕这样简单的任务,都可能变成一场冗长的谈判。相比之下,共享内存系统依靠硬件使这种协作感觉像是瞬时完成的。对共享变量的原子更新可能只需要几百纳秒,这是一个工程奇迹。而在一个节点可能失效的分布式系统中,要达到同等级别的共识,则需要复杂的共识协议,其速度可能慢上数千倍。共享内存的魅力在于其绝对的速度和简单性——至少在原则上是如此。

但你如何真正建造这个共享的“雕塑”呢?在最基础的层面,它并非魔法,而是一种复杂的硬件布局。我们可以使用诸如​​双端口RAM芯片​​等组件来构建共享内存空间,这种芯片有两个独立的访问端口,允许两个不同的CPU同时进行读或写。然后,我们使用逻辑门和解码器来协调在任何时刻哪个CPU正在寻址哪个内存芯片。这种物理现实,即电子在硅片上的舞蹈,创造了统一内存空间的强大抽象。然而,这个美丽的愿景背后隐藏着一系列深刻的挑战,而解决这些挑战的方案正是现代计算机体系结构真正天才之处。

第一个巨大挑战:单一内存的幻象

处理器速度的秘诀在于其​​缓存​​。把它想象成每个工匠都放在口袋里的一个小小的个人记事本。他们不用为每个小细节都走到主雕塑那里,而是可以记下他们正在处理的部分,并参考自己的记事本。这样快得多。但问题也随之而来。如果一个工匠,我们称她为Alice,雕刻了一个新的细节,却只更新了她的私人记事本,那么另一个工匠Bob,看着自己的记事本,就会看到雕塑的过时版本。他们对现实的看法出现了分歧。这就是​​缓存一致性问题​​。

我们如何确保所有记事本都保持一致呢?现代处理器通过​​缓存一致性协议​​来解决这个问题,这些协议就像对话的规则。在一个较小的系统中,所有处理器都通过一个共享​​总线​​连接,这个总线就像一个房间,每个人都能听到其他人的声音。当一个处理器想要写入其缓存中的某个内存位置时,它必须首先在总线上宣告其意图。这被称为​​监听(snooping)​​。

这种宣告主要有两种策略:

  1. ​​写-失效(Write-Invalidate)​​:这是最常见的方法。当Alice想要更改一个值时,她在总线上宣告:“各位,请划掉你们关于X区域的笔记!我要修改它。”所有其他拥有该数据副本的缓存都会将其标记为​​无效(Invalid)​​。然后,Alice的缓存行进入​​修改(Modified)​​状态,表示她现在是该正确版本的唯一所有者。如果Bob稍后需要读取X区域,他会发现自己的笔记无效,这迫使他请求新版本,Alice会提供这个新版本。这会产生一次延迟,或称​​停顿(stall)​​,因为他需要获取更新后的数据。

  2. ​​写-更新(Write-Update)​​:另一种策略是Alice宣告:“各位注意,X区域的新值是12!”其他每个拥有X副本的缓存只需用新值更新其记事本。数据在所有缓存中保持​​共享(Shared)​​状态。这种方法很有吸引力,因为如果Bob在Alice写入后马上需要读取X,他能立即在自己的记事本上找到正确的值,零停顿。

这些策略带来了不同的性能权衡。写-更新对于写后频繁读取的场景似乎更好,但它每次写入都会产生总线流量。写-失效只在第一次写入时(为了使其他副本失效)和随后的未命中时产生流量,这通常更有效率。

随着时间的推移,这些简单的想法演变成了复杂的协议,如​​MESI(Modified, Exclusive, Shared, Invalid)​​。​​独占(Exclusive)​​状态是一个巧妙的优化:如果一个处理器读取了没有其他人拥有的数据,它会将其标记为独占。然后,它可以悄无声息地写入该数据,无需通知任何人,因为它知道不存在其他副本。这个简单的补充节省了一次总线事务。

进一步的改进催生了​​MOESI​​协议,该协议增加了一个​​持有(Owned)​​状态。此状态解决了MESI中的一个特定低效问题。在MESI中,当一个缓存持有一个脏行(处于Modified状态)而另一个缓存请求读取它时,所有者必须先将数据写回主内存,然后才能共享它。这确保了共享数据总是干净的(与内存一致)。MOESI协议意识到这通常是不必要的。​​持有(Owned)​​状态允许一个缓存成为脏行的“所有者”,同时仍允许其他缓存拥有共享的只读副本。当另一个缓存请求数据时,所有者直接提供数据,而无需写入内存。这避免了一次缓慢的内存写入,减少了带宽消耗和延迟,展示了通过协议创新不断追求性能的努力。

第二个巨大挑战:扩展通信规模

在共享总线上进行监听对于少数几个核心来说效果很好。但对于拥有几十甚至几百个核心的系统呢?单一总线会变成一条交通拥堵的单行道。这是一个根本性的可扩展性限制。解决方案是放弃广播总线,转向更具可扩展性的互连方式,如点对点网络。但没有了广播媒介,处理器如何使其他副本失效呢?

这就引出了​​基于目录的一致性(directory-based coherence)​​。每个内存块被分配一个“主节点”,该节点维护一个目录——一个记录了当前哪些处理器拥有该块副本的列表,而不是向所有人广播。当一个处理器想要写入时,它向主节点发送一个请求。主节点随后在其目录中查找共享者,并只向它们发送有针对性的失效消息。

这看起来更具可扩展性,但也有其代价。对于一个被PPP个处理器共享的块,写入者向目录发送一个请求,目录发送P−1P-1P−1个失效消息,等待P−1P-1P−1个确认,最后向写入者发送许可授权。这总共需要2P2P2P条消息!相比之下,监听总线用一条广播消息就完成了同样的失效操作。这揭示了一个关键的权衡:监听简单但无法扩展,而目录扩展性更好,但对于广泛共享的数据开销更高。这种开销甚至可能使主节点本身的处理能力饱和。

更糟糕的是,如果目录这个有限的硬件资源空间不足以追踪所有的共享者,会发生什么?一个简单的后备方案是直接向系统中的所有P−1P-1P−1个节点广播失效消息,从而产生一场堵塞网络的“广播风暴”。一个更优雅的解决方案是使用​​层次结构​​。我们可以将PPP个节点分组成P\sqrt{P}P​个集群,每个集群有P\sqrt{P}P​个节点。这样,目录只需要追踪哪个集群包含共享者。一个探测消息被发送到目标集群,然后由该集群进行本地搜索。这将消息数量从O(P)O(P)O(P)减少到O(P)O(\sqrt{P})O(P​),这是可扩展性的巨大进步。

大型系统中节点和内存的这种物理分布导致了​​非一致性内存访问(NUMA)​​。访问同一插槽(本地)上的内存比访问不同插槽(远程)上的内存要快得多。这带来了切实的性能影响。对于一个高争用的共享数据结构,比如一个无锁队列,原子操作的平均延迟成为快速本地访问和慢速远程访问的加权平均值。因此,NUMA机器上的总吞吐量可能显著低于具有相同平均延迟的理想化UMA(一致性内存访问)机器,这表明物理拓扑如何直接影响软件性能。

第三个巨大挑战:程序员的交互规则

即使有硬件英勇地维持着一致性,最后一个微妙的挑战依然存在:程序员能保证看到什么样的内存操作顺序?最直观的模型是​​顺序一致性(Sequential Consistency, SC)​​,它保证任何执行的结果都如同所有线程的所有操作被简单地交错在某个全局序列中,并且该序列尊重每个独立线程的程序顺序。

然而,为了最大化性能,现代处理器采用了​​宽松内存一致性模型​​。它们可以自由地对内存操作进行重排序!一个处理器可能在write A之前执行write B,即使你在代码中是按相反顺序写的。这可能导致令人费解的错误。以一个网页浏览器为例:一个布局线程准备新的显示数据(写入x),然后设置一个标志表示准备就绪(写入y=1)。排版线程看到y=1后读取x。如果处理器重排了写操作,排版线程可能会看到标志已设置,读取x,却得到了旧数据,导致屏幕上出现损坏的帧。

为了防止这种混乱,程序员必须使用​​同步原语​​。它们就像栅栏,迫使处理器尊重特定的顺序。虽然存在重量级的硬件栅栏,但现代编程语言提供了更轻量级的选项,如​​释放-获取语义(release-acquire semantics)​​。当布局线程写入标志y时,它使用​​释放存储(release store)​​。这告诉处理器:“确保在此存储操作之前的所有内存写入对所有人都可见。”当排版线程读取标志时,它使用​​获取加载(acquire load)​​。这告诉处理器:“在此加载完成之前,不要执行任何后续的内存读取。”当获取操作读取了释放操作写入的值时,一个happens-before关系就建立了。对x的写入保证发生在对x的读取之前,从而以最小的开销解决了数据竞争问题。

并发的原子构建块

所有同步的核心在于​​原子操作​​:由硬件保证的、不可分割执行的指令。其中最重要的两个是加载链接/条件存储和比较并交换。

​​加载链接/条件存储(Load-Linked/Store-Conditional, LL/SC)​​是一种乐观机制。一个线程对一个内存字执行加载链接操作,这会读取该值并请求硬件“监视”该位置。然后,线程执行一些计算。最后,它尝试执行条件存储。只有在期间没有其他线程写入该内存位置的情况下,存储才会成功。如果失败,线程就知道发生了冲突,必须重试。这个循环的性能在很大程度上取决于争用程度。当有NNN个核心在争用时,每次尝试都有一定的失败概率ppp。一次成功之前的预期重试次数遵循几何分布,而整个系统的吞吐量可以建模为这个争用概率和每次尝试周期所用时间的函数。

​​比较并交换(Compare-And-Swap, CAS)​​是另一个强大的原语。它接受三个参数:一个内存地址、一个期望值和一个新值。它原子地检查该内存地址当前是否持有期望值。如果是,就将其更新为新值并报告成功;否则,它什么也不做并报告失败。

CAS是无锁数据结构的得力工具,但它隐藏着一个臭名昭著的陷阱:​​ABA问题​​。想象一个线程想要从一个无锁栈中弹出一个元素A。它读取了头指针,即A。在它能将头指针CAS为A->next之前,它被中断了。其他线程弹出了A,压入了其他东西,然后又压入了一个新节点,而这个新节点恰好被分配在与原始A相同的内存地址上。当我们的第一个线程恢复时,它执行CAS操作:“头指针仍然是A吗?”是的,它是!CAS成功了,但它破坏了栈,因为它不是同一个A了。解决方案是使用​​带标签的指针​​。头指针不再只是一个指针,而是一个对:(指针, 版本标签)。每次成功更新都会增加标签。现在CAS变成了:“头指针仍然是(A, v1)吗?”。中间的操作会把头指针变成(A, v2),所以CAS会正确地失败。这个针对一个微妙错误的优雅修复是有代价的:原子字现在更大了(例如,128位而不是64位),这在每次尝试时都会消耗更多宝贵的内存带宽。

从连接内存芯片的物理线路,到内存一致性的微妙逻辑,再到实现并发软件的原子操作,共享内存多处理器是定义计算机科学的分层、层级解决方案的明证。这是一场持续的探索,旨在对抗物理和并行的混乱现实,以维护一个简单而强大的幻象——单一、统一心智的幻象。

应用与跨学科联系

在遍历了共享内存多处理器的基本原理之后,我们现在到达了一个最激动人心的时刻:亲眼见证这些思想的实际应用。理解缓存一致性的抽象规则或原子指令的机制是一回事;而看到这些概念如何为驱动我们世界的软件和系统注入生命力,则完全是另一回事。正是在这里,该架构的真正美妙之处得以展现——它不是一堆零散部件的集合,而是一个统一、协调的整体,为从操作系统到计算科学等领域的问题提供了解决方案。我们将看到,让多个处理器协同工作的挑战不仅仅是技术障碍,它们也反映了在协调、沟通和资源管理等方面的基本问题,这些问题无处不在,从繁忙的城市十字路口到合作探索发现的科学家团队。

协作的艺术:打造同步原语

并行编程的核心是一个简单的问题:如果两个处理器需要访问同一份数据,它们如何避免互相干扰?最简单的答案是锁——一种数字化的“发言权杖”,确保一次只有一个处理器能进入代码的临界区。但处理器如何等待锁,是一门精巧的艺术。一种天真的方法是“自旋锁”,即等待的处理器疯狂地重复询问:“轮到我了吗?”这会在系统的共享内存总线上引发一场消息风暴,一场一致性请求的交通堵塞,可能使整个系统陷入停顿。

一个远为优雅的解决方案是让处理器退让,在重试前随机等待一段时间。但等待多长时间才合适呢?直觉和一些数学模型给出了一个优美的答案。理想的等待时间应与争用程度成正比。如果许多处理器都在等待,每个处理器都应该等待更长的时间。这种自适应策略最大限度地减少了总线流量,同时确保锁一旦空闲就能迅速交接,在耐心和渴望之间达到了完美的平衡。这是一种写入机器逻辑深处的礼貌原则。

有了这些基本的协作工具,我们就可以构建更复杂的算法。思考“领导者选举”问题,这是任何分布式系统中的一项基本任务,即必须选择一个成员来协调整个群体。在共享内存机器上,使用像加载链接/条件存储(LL/SC)这样的原子指令,可以极其优雅地解决这个问题。想象一下,NNN个核心中的每一个都试图将自己的ID写入一个初始为零的共享内存位置。第一个成功写入的就成为领导者。通过使用LL/SC并让每个核心在随机延迟后开始尝试,系统自然而公平地选举出一位领导者。这个过程的数学分析表明,由于对称性,每个核心被选中的机会完全相等(\1/N$$),并且完成选举的预期时间会随着竞争者数量的增加而优雅地扩展。这是一场在纳秒内完成的、去中心化的民主选举。

但当争用变得极端,几十甚至几百个核心都试图更新同一个计数器时,会发生什么?这在大型模拟和数据分析中是常见场景。一个简单的锁会成为严重的瓶颈。在这里,我们可以从组织结构中汲取灵感。与其让每个人都向单一的管理者汇报,我们可以形成一个层级结构。一个“软件合并树”正是这样做的:线程被组织成一棵树,更新请求在向根部传递的过程中在每个层级被合并。只有一个合并后的更新会触及共享计数器。结果随后被分发回树的下层。这种“分而治之”的策略将一个串行化的瓶颈转变为一个高度并行的过程,其性能通常远超中心化方法。

系统的协同:操作系统与架构感知

现在,我们的视野从单个算法扩展到整个系统。共享内存多处理器不仅仅是硬件;它是硅片与操作系统(OS)之间的合作关系。这种合作关系最强大的体现之一是内存映射文件的概念。通过使用像带有MAP_SHARED标志的mmap这样的系统调用,操作系统可以指示硬件将磁盘上文件的页面直接映射到多个进程的虚拟地址空间中。

其神奇之处在于,这些不同的虚拟地址都指向内存中完全相同的物理页面。当进程P1P_1P1​中的一个处理器写入这块内存时,硬件的缓存一致性协议会自动确保进程P2P_2P2​中的处理器能看到这个更新,通常无需操作系统介入。这为进程间通信和文件I/O提供了一种极其高效的机制。它也阐明了像msync这类其他系统调用的作用,它们不是为了确保处理器之间的一致性(硬件已经做到了),而是为了完成将内存中的数据安全地传输到磁盘上的持久存储这个慢得多的任务。

在采用非一致性内存访问(NUMA)架构的现代服务器中,这种合作关系变得更加关键。在NUMA系统中,处理器访问连接到自己插槽的内存(本地内存)比访问连接到另一个处理器插槽的内存(远程内存)快得多。对于一个内存密集型应用来说,数据存放在远处就像在厨房做饭而冰箱却在另一个房间。因此,操作系统必须扮演一个智能调度者的角色。通过观察哪些线程共享数据,一个NUMA感知的操作系统可以将协作的线程及其数据共同定位在同一个NUMA节点上。这种巧妙的布局行为可以通过将缓慢的远程内存访问转变为快速的本地访问,带来显著的性能提升。整体性能的提升是阿姆达尔定律的一个完美例证:提升一个频繁操作(内存访问)的性能,可以对总执行时间产生巨大影响。

极致并行:GPU的世界

现在我们转向一种特殊的共享内存多处理器,它已经彻底改变了科学计算、机器学习和计算机图形学:图形处理单元(GPU)。GPU将“众核”理念推向极致,拥有数千个简单的处理核心,设计用于协同解决同一个问题。但伴随这种强大能力而来的是一种新的编程模型,程序员被赋予了对丰富内存层次结构的显式控制权。

与CPU基本透明的缓存不同,GPU程序员必须管理数据在不同内存空间的布局:

  • ​​全局内存(Global Memory):​​ GPU庞大的主内存,类似于CPU的RAM。它容量大但速度慢。要高效访问它,需要一种称为​​内存合并(memory coalescing)​​的特定规则,即一个线程组(一个“warp”)中的线程访问连续、对齐的内存块。这使得硬件能将许多单独的请求捆绑成一个大的事务,就像一艘货船为整个社区运送货物。
  • ​​共享内存(Shared Memory):​​ 一块极小但速度极快的片上便笺式存储,由一个线程块共享。它充当用户管理的缓存,一个工作台,数据可以从全局内存中被带到这里进行高速协作处理。
  • ​​常量内存和纹理内存(Constant and Texture Memory):​​ 具有特殊用途缓存的只读内存。常量内存为向一个warp中的所有线程同时广播单个值而优化,非常适合物理常数或配置参数。纹理内存为空间局部性而优化,在线程访问“邻近”但不完全连续的数据时很有帮助。

在GPU计算中取得成功的关键在于精心编排一场数据移动的芭蕾,最大限度地减少与缓慢的全局内存之间的流量。一个关键指标是​​占用率(occupancy)​​,它衡量一个内核(kernel)让GPU处理单元保持繁忙的效率。一个程序的瓶颈可能在于它使用的寄存器数量,或者它需要的共享内存量。找到正确的平衡就像一个谜题,需要寻找最适合硬件限制的内核配置,以最大化并行度并隐藏不可避免的内存操作延迟。

让我们通过两个计算科学中的经典问题来看看这一点在实践中的应用。首先是矩阵乘法。一个简单的实现会让每个线程计算结果矩阵的一个元素,这会导致大量的非合并和冗余的全局内存访问。高性能的解决方案使用分块(tiling)。输入矩阵的小方块被加载到快速的共享内存中。然后,块中的所有线程仅使用这个快速工作台上的数据执行必要的乘法和加法。选择块大小TTT是一项精湛的协同设计练习,需要平衡三个约束:两个块必须能放入共享内存,块的维度必须选择以避免“bank冲突”(共享内存内部的交通堵塞),并且块大小T×TT \times TT×T不能超过硬件的限制。避免bank冲突的最佳解决方案通常涉及一个令人惊讶的数论技巧,比如选择一个奇数的块维度来确保访问能完美地错开到各个内存bank上。

第二个例子是模板计算(stencil computation),这在天气预报或流体动力学等物理模拟中很常见。在这里,网格中的每个点都根据其邻居的值进行更新。同样,分块是关键。一个线程块将一个数据块加载到共享内存中,但它还必须加载一个“光环”或“幽灵区”——即来自该数据块邻居的额外数据边界——以便正确计算数据块边缘的更新。一种更先进的技术,时间融合(temporal fusion),将此更进一步。与其在一次更新后将数据块写回全局内存,为什么不在快速的共享内存中直接计算多个时间步呢?这需要加载一个更宽的初始光环,但回报是巨大的:数据被多次重用,极大地降低了内存流量与计算的比率。这是原则的终极体现:一旦你将数据放入最快的内存中,就尽可能多地对其进行处理。

最后,我们通过考虑最深层次的一致性来完成这个循环。我们认为程序是指令,而它们操作的数据就是数据。但指令本身也只是存储在内存中的数据。如果一个处理器在内存中写入新的指令,而另一个处理器正要执行它们,会发生什么?这种情况,被称为自修改或交叉修改代码,提出了终极的一致性挑战。为了使其正常工作,我们必须克服三个障碍:

  1. 新指令的数据写入必须在执行处理器被告知跳转到它们之前变得可见。这需要一个​​数据内存屏障(data memory barrier)​​。
  2. 执行处理器的指令缓存(可能持有旧代码的陈旧副本)必须被更新或失效。如果I-cache监听总线流量,这可以自动发生,否则可能需要显式的软件命令。
  3. 处理器的指令流水线(可能已经获取并解码了旧指令)必须被清空。这需要一个特殊的​​指令同步屏障(instruction synchronization barrier)​​。

成功应对这一挑战展示了架构的深刻统一性。维持我们数据一致性的相同一致性机制,也确保了处理器执行的指令本身的完整性。从自旋锁的简单舞蹈到GPU内核的复杂编排,再到指令流本身的一致性,共享内存多处理器作为通信与协作的优雅原则的见证而存在,并将其扩展到每秒数十亿次操作的规模。