PGAS并行编程模型:融合共享内存与消息传递的高性能计算方案
PGASPartitioned Global Address Space这个缩写在高性能计算圈子里这几年被提得越来越多。做并行开发的老手基本都绕不开两个经典模型以OpenMP为代表的共享内存模型和以MPI为代表的消息传递模型。一个面对多核开发效率高一个支撑了大规模集群上的绝大多数科学计算。但只要你真正动手写过大程序就会发现这两个模型都挺拧巴——共享内存跨不了节点消息传递做细粒度随机访问又极其痛苦。PGAS想解决的就是这个“既要又要”的问题既给你一个能直接读写的全局地址空间又把数据按分区逻辑放在各自的进程里让并行程序既好写、又能把性能掌握在自己手里。这篇文章我准备从模型原理讲到具体实现再落到工程选型和踩坑经验适合在做并行程序开发、对通信性能有要求、或者单纯想系统搞懂PGAS是怎么回事的读者。1. 为什么会有PGAS两个经典模型的两难1.1 共享内存模型的天生边界共享内存模型在单机时代几乎是统治级的存在。OpenMP写起来确实爽一个#pragma omp parallel for就能把循环摊到所有线程上大家读写同一块内存根本不用考虑数据在哪里因为对线程来说内存就是一个统一的平面。但问题在于这个“平面”只在单机内成立一旦代码需要跨节点运行OpenMP就完全没辙了因为每个节点都有自己独立的内存空间指针在进程A里指向的地址在进程B里没有任何意义。当然业界也不是没想过把共享内存扩展到分布式环境最典型的就是Distributed Shared Memory分布式共享内存方案。这个思路是用虚拟内存机制或者库来模拟“大家访问同一块虚拟地址”底层自动把缺页的数据搬过来。听着很完美实际用起来性能惨不忍睹因为操作系统在缺页时根本不知道程序下一步会访问哪一页只能靠页迁移和缓存策略瞎猜猜错了就是一场灾难性的页抖动。所以这种方案在高性能计算里基本退出了主流舞台大家还是老老实实回到显式通信这条路上。1.2 消息传递模型的繁琐与开销MPI是另一种极端。它把分布式内存的现实完全摊开给程序员你想让进程A拿到进程B的数据就必须用MPI_Send和MPI_Recv显式地发送和接收。这种双边通信机制非常严谨谁发谁收写得很清楚也正因为这种明确性让它能稳定运行在超大规模系统上。但代价是开发效率极其低下。写过MPI的人都知道通信代码永远比计算代码难写。发数组前得先确定数据类型、长度、对方rank号、通信标签接收方得提前准备好缓冲区收发不匹配就是死锁做全局归约要写一堆buffer管理和tag管理。更麻烦的是MPI通信的本质是CPU参与的数据搬运发送方要把数据打包再交给网络设备接收方接到数据后还得中断CPU去处理。整个流程里CPU像快递柜前的分拣员每一次大促都会忙到原地转圈而大量小消息场景下的延迟和CPU开销就是这么堆出来的。1.3 PGAS的破局点全局视图和可控局部性PGAS的路线很有意思它把这两种模型的优点揉在一起。在PGAS的抽象里系统依旧给程序提供的是一个完整的全局地址空间任何进程都可以通过地址访问其他进程里的数据这是共享内存模型的体验。但和传统DSM不一样的是这个全局地址空间被静态地切成了很多块每一块固定地“归属”某一个进程程序员和编译器都知道每一块数据到底在谁手里这又是消息传递模型的核心优势——数据局部性。你访问一个远程地址时运行时会给你做单边通信数据直接从一个进程的内存搬到另一个进程的内存不需要对方进程主动配合也不需要对方CPU参与。用大白话说PGAS就像一个大仓库分成了若干格子每个格子归一个员工管。任何员工都可以取别人格子里的货但取之前你心里很清楚这个货在哪个格子去取要花多少体力你大致有数而不是像DSM那样连货在哪都让系统猜。正是这种“全局地址静态分区”的设计让PGAS在性能上远比DSM可靠在编程体验上又远比MPI舒适。2. 核心设计拆解PGAS到底是怎么工作的2.1 分区地址空间与归属判定PGAS模型里最基本的执行单位在不同语言里有不同叫法UPC里叫threadsCoarray Fortran里叫imagesChapel里叫localesOpenSHMEM里叫PEsProcessing Elements。但本质都一样每个执行单位都拥有全局地址空间的一部分这个“拥有”关系是静态不变的不会随着程序运行而改变。每个指向全局对象的地址都可以按规则映射到具体的归属PE这个计算极其廉价通常就是一个移位加取模。这意味着两件事。第一任何PGAS程序都能在编译期或者启动阶段就确定所有数据的位置关系运行时完全不需要像DSM那样做动态页表管理和迁移决策省去了一大块运行时开销。第二程序员写代码时心里得有一根弦某个读操作究竟是访问本地内存还是触发远程访问远程访问的成本通常比本地访问高一个数量级如果代码把全局地址空间当成普通的共享内存来用动不动就跨节点读数据性能会非常难看。所以PGAS并不是让程序员“忘记”分布式现实而是把分布式现实的成本变得透明且可控。2.2 单边通信PUT/GET与隐式流水PGAS最核心的通信原语是单边通信one-sided communication对应两个基本操作PUT和GET。PUT是“把我的数据写到某个PE的地址里去”GET是“把某个PE地址里的数据读回来”。这两类操作的最大特点是目标PE完全不需要知道这次通信的发生。发送方发出一个PUT之后数据就通过网络直接写入目标内存目标进程不需要调用任何接收函数CPU也不会被中断去处理通信事件。这个对比很重要。MPI的Send/Recv是“双边握手”双方必须同时在线才能完成交易PGAS的PUT/GET是“单向投递”快递员到了直接把包裹扔进收货人的仓库收货人甚至不用下楼签收。正是因为这个特性PGAS通信能和计算实现天然的异步重叠。你在一个循环里发出了几十个PUT命令网络引擎可以趁CPU继续算下一段数据的时候把这些远程写操作慢慢消化掉几乎不用占用计算线程的时间。我看过不少benchmark在InfiniBand这类支持RDMA的网络上小消息的单边通信延迟通常比MPI双边通信要低很多因为消息传递那套接收端的软件匹配和缓冲管理机制直接省掉了。这不是说MPI不行而是PGAS在设计上就没打算让通信双方都“必须出现”这在很多算法里能省掉大量同步和协调代码。2.3 同步模型没有“自动一致”这回事既然通信是单边的那同步就成了一把双刃剑。PGAS语言都提供一套显式的同步原语比如barrier、fence、lock和原子操作。为什么需要这些因为PUT/GET操作只保证数据最终能到达或者读出但不保证你在发出操作之后立即可见。你在PE0上执行了一次PUT把数据写到PE1的数组里紧接着PE1开始读这个数组它读到的可能是旧值。处理器和网络都有重排和缓冲机制不做同步的后果就是数据竞争。这里可以用一个简单的例子说明。在UPC里upc_barrier负责全局同步upc_fence负责让当前线程之前发起的远程操作在后续操作之前完成。如果你在PUT之后没有加fence或者barrier就立刻让其他PE去读程序就有可能出现“明明写过了读却是旧值”的诡异现象。很多从OpenMP转过来的新手最容易踩这个坑因为OpenMP的threadprivate和flush语义虽然复杂但大家写简单代码时往往意识不到数据竞争的存在。2.4 语言级扩展和库级实现的区别PGAS不是一种具体的编程语言而是一类编程模型的统称。落地方式大致分两种一种是语言级扩展比如UPC在C语言里加入shared修饰符Coarray Fortran在Fortran标准里直接加入了协数组语法Chapel干脆是一门全新的语言另一种是库级实现比如OpenSHMEM和Global Arrays它们给你提供一套函数库你可以在C/C/Fortran里调用它们提供的远程访问接口。语言级扩展的优点是编译器能感知全局地址空间可以进行自动优化和越界检查写起来最自然缺点是新语法有学习成本而且工具链的成熟度参差不齐。库级实现的优点是与现有代码集成容易你不需要为了用PGAS把整个项目改成一种新语言而且底层运行时通常经过了大量性能打磨缺点是所有远程访问都要用显式函数调用表达出来代码里稍微多了一些“仪式感”。3. 主流实现UPC、Coarray Fortran、Chapel与UPC3.1 UPCC语言里长出来的PGASUPCUnified Parallel C是PGAS阵营里资历最老的选手之一它对标准C做了精简但有效的扩展。核心概念是shared类型的变量和数组声明方式像这样#define N 1000 shared double a[N]; void main() { int i; upc_barrier; for (i MYTHREAD; i N; i THREADS) { a[i] compute(i); } upc_barrier; // 从这里开始所有远程线程都能读到写好的a double local_sum 0; for (i 0; i N; i) { // affinity_of可以查询归属线程 if (MYTHREAD upc_affinityof(a i)) { local_sum a[i]; } } }UPC的关键语法有三个shared声明全局共享对象MYTHREAD和THREADS表示当前线程号和总线程数upc_forall可以按循环分布方式指定某个迭代应该在哪个线程上执行。它的底层靠编译器把远程访问转换成GASNet或者具体网络API的调用所以性能比纯手写MPI要好写很多同时保留了C语言没有虚拟机包袱的优势。3.2 Coarray FortranFortran 2008的原生选择Fortran在科学计算领域的地位不用多说而Coarray FortranCAF是Fortran 2008标准直接纳入的并行模型。它的核心是协数组coarray通过在变量名后面加方括号指代远程镜像的数据program coarray_sum implicit none integer :: i, me, np real :: x[*], local_sum me this_image() np num_images() x compute_value() ! 每个image都有自己的x sync all ! 等待所有image完成 if (me 1) then local_sum x[2] x[np] print *, collected , local_sum end if end programCAF的优势在于Fortran标准加持所以编译器支持相对到位Cray、Intel、GNU都提供了可用的实现。而且Fortran程序员上手CAF几乎没有额外负担语法和原生数组非常接近。不过CAF能表达的并行模式相对偏常规适合规则网格、线性代数一类结构规整的应用。3.3 Chapel为大规模并行而生的新语言Chapel是Cray主导设计的一门语言也是目前PGAS阵营里抽象层次最高的一位。它把PGAS思想推得更远引入了domain、locale、begin等一等公民概念。在Chapel里你可以在整个计算系统级别的locale上声明一个分布式数组然后像写串行程序一样直接对全局数组切片操作语言会自动处理跨节点的通信use BlockDist; const MySpace blockDist.createDomain({1..1000}); var A: [MySpace] real; forall i in MySpace { A[i] compute(i); } // 编译器自动把循环里的远程访问转换成PGAS通信Chapel的表达力确实强它把循环并行、任务并行、数据并行都塞进了一套统一的编程模型里。但代价是语言体系庞大编译器优化还在持续演进如果你不是专门做一个长期的大规模项目上手Chapel的投入产出比需要掂量一下。3.4 OpenSHMEM和UPC库与C的结合OpenSHMEM可以看成标准化的SHMEM库它把PGAS的底层原语暴露成C API。你在程序里调用shmem_put、shmem_get、shmem_barrier_all等函数完成远程访问和同步。由于它不改变语言语法可以无缝接入现有的C/C/Fortran项目还保留了最大的底层控制力适合对性能细节有极致追求的团队。UPC则是UPC在C世界的延续把PGAS和现代C的模板、lambda、future结合在一起。它支持分布式对象、异步任务、future/promise模式还能跟MPI混编是目前很多新一代科学计算框架选择的对象。UPC代码大概长这样#include upcxx/upcxx.hpp int main() { upcxx::init(); dist_object std::vectordouble arr { std::vectordouble(1000) }; // 每个rank填充一部分本地向量 for (int i upcxx::rank_me(); i 1000; i upcxx::rank_n()) { (*arr)[i] compute(i); } upcxx::barrier(); double total 0; // 读取所有rank上的数据 for (int i 0; i 1000; i) { total (*arr)[i]; // 跨rank访问由运行时处理 } upcxx::finalize(); }UPC把“分布式对象异步操作”这两个现代并行编程最常见的需求直接放在语言层解决而且底层的GASNet-EX运行时优化力度很大实测通信性能非常能打。3.5 选型对比速查实现形式适合人群优势主要短板UPC语言扩展C有C基础、想快速体验PGAS的团队语法轻、工具链相对完整语言生态老新特性少Coarray Fortran语言扩展Fortran科学计算、Fortran存量代码标准内置、编译支持好抽象层次偏低表达复杂并行模式吃力Chapel独立语言新项目、追求高层抽象表达能力最强开发效率高编译器成熟度、生态需要验证OpenSHMEM库API已有C/C/Fortran项目侵入性小、底层控制力强代码仪式感重全局对象表达不自然UPCC库/语言侧扩展高性能C项目、GPU异构表达力强、异步支持好、互操作好学习曲线偏陡依赖C进阶特性4. 底层实现机制从一段PGAS代码到网络硬件4.1 PGAS运行时在做什么你写下一行a[i] 1如果a是全局数组且i对应的归属PE是别的节点这一行不会直接编译成一条普通的store指令而是会被拆成一次远程写操作。具体流程是编译器识别出这是跨PE访问生成调用运行时接口的代码运行时根据地址归属关系确定目标PE的rank然后通过底层通信库发起一次单边PUT把数据从源节点的用户缓冲区搬到网络设备再由网络传输到目标节点内存。目标节点上的通信库收到数据后直接把它写入目标地址不需要目标PE的代码执行任何接收操作。这段流程里最关键的优化空间就是减少每一层的拷贝。成熟的PGAS运行时会尽量避免数据从用户缓冲区复制到系统缓冲区再复制到网卡的路径而是尽量用零拷贝和RDMA技术让数据直接在用户缓冲区和网卡之间流动。这也是为什么PGAS程序在某些小消息场景下能比MPI快很多省掉的底层内存拷贝和软件处理开销相当可观。4.2 GASNetPGAS世界的“交通运输网”GASNet是伯克利开发的可移植通信中间件也是UPC、Chapel、UPC等大量PGAS语言和框架的默认通信层。它提供两类核心机制一类是RMARemote Memory Access就是单边PUT/GET另一类是Active Message它比RMA更灵活可以在远程PE上触发一个回调函数让远程节点主动执行一段代码。Active Message在很多和图算法、稀疏线性代数相关的算法里非常有用因为它能把“取出数据”和“处理数据”合并在一次远程通信里完成省掉一轮往返延迟。GASNet在不同硬件上会选择不同实现有InfiniBand的机器上走verbs/RDMA有Cray网络时走Aries/Gemini的uGNI接口普通以太网上则跑在GASNet的通用层。这种多后端设计让PGAS程序写一套源码就能在不同网络上获得接近硬件极限的性能属于整个PGAS生态里吃重极深的基础设施层。我自己调试PGAS程序时经常先看GASNet环境变量的配置很多性能问题和通信参数都跟这层有关。4.3 硬件角色RDMA和网络原子操作现代高性能计算网络把PGAS的很多原语直接做进了硬件。最典型的是RDMARemote Direct Memory Access它允许网卡直接读写远端内存全程绕过远端CPU。在支持RDMA的网络里一次PGAS PUT操作的成本接近本地内存拷贝加一个网络往返远端CPU完全无感。这个特性对于大规模节点间的细粒度数据交换非常关键因为它彻底消除了“接收端CPU处理每个消息”这个性能瓶颈。除此以外不少网络协议栈还支持硬件原子操作比如远程fetch-and-add、compare-and-swap。PGAS运行时会把这些原子操作映射到硬件能力上从而实现分布式数据结构比如分布式计数器、锁变量的高效更新。这在图计算和分布式哈希表里非常好用程序不需要加一把全局大锁去保护远程变量的修改直接一行原子操作就能安全完成。5. 性能优化与常见坑这些问题我是真踩过5.1 数据局部性PGAS性能的第一要素PGAS给你的是全局地址空间但代价并没有消失只是从显式的MPI消息变成了隐式的远程访问。很多新手把PGAS当OpenMP用用shared数组然后不管归属关系每个PE都去远程读别人的数据结果性能惨不忍睹。正确姿势是尽量让计算发生在数据所在的PE上你在写循环时优先让迭代的分配遵循数组的归属分布把远程访问比例压到最低。一个我自己反复遇到的例子是分布式矩阵求和。如果让每个PE随机访问矩阵的所有元素相加性能会随节点数量增加而迅速崩溃改成先求本地分块的部分和再执行一次全局归约时间能差出一个数量级。也就是说你在写PGAS代码时心里要有“数据主人”的概念算谁的数据最好就安排谁去算远程读只能作为零星的、无法避免的补充而不是主路径。5.2 异步进度带来的死锁问题这是PGAS编程里最容易翻车的一处。因为PUT/GET是单边的你可能会认为不需要双方协调所以在两个PE之间设计了一个互相等待的循环比如PE0等PE1的flag置位PE1等PE0的数据到达。听起来没问题但很多PGAS运行时默认不会为通信专门启动额外的监听线程也就是说通信进度需要程序主动推进。如果PE0在while(flag 0) {}里死等CPU一直跑在那个空循环上底层通信引擎很可能没机会得到调度PE0永远看不到flag更新直接活锁死循环。解决思路一般是三个一是用upcxx::progress()或者类似的接口在等待循环里主动推进通信进度二是启用运行时的独立进度线程很多库用环境变量控制三是干脆避免在一个线程里自旋等待远程变量而是显式使用同步原语比如barrier、fence来完成握手。我在UPC项目里遇到过一次性能诡异退化的bug最后定位就是少了progress()通信引擎饿死导致所有远程操作排队超时。5.3 调试和性能分析的工具现状PGAS的调试比MPI要麻烦一些因为远程访问发生在运行时库深处传统调试器往往只看到一个系统调用或网卡操作很难映射回源代码里的那个远程赋值。好在现在主流调试器都有一定支持TotalView对UPC/Coarray有专门的线程视图GDB搭配UPC编译器也能勉强定位同步错误。性能分析方面TAU和HPCToolkit能识别PGAS通信事件我建议项目组在早期就把这类工具接入CI流程等性能问题到了中后期再回查会非常痛苦。模拟器也是个可用方案。很多PGAS运行时支持把计算节点跑在同一台多核机器上这时GL的通信会退化成共享内存的快速拷贝调试并发逻辑非常方便。我一般先在单机上用多进程模式验证正确性确认没有死锁和数据竞争后再上真实集群做网络环境的性能测试。5.4 常见问题速查症状可能原因排查与解决程序卡死无输出等待循环没有推进通信进度加入progress()、开启运行时进度线程、改用barrier性能随节点数下降远程访问比例过高局部性差调整计算分配让迭代归属数据主人数据读出来是旧值缺少fence或barrier同步在PUT后、GET前插入正确的同步原语共享变量更新冲突多个PE并发写同一远程变量使用原子操作或锁变量单机多进程正常上集群就慢通信层对网络后端配置不对检查GASNet或运行时环境变量确保走RDMA而不是TCP回退6. 一些项目选型和上手的个人经验6.1 我什么时候会选PGAS做了几年并行计算我对“什么时候不该用PGAS”已经有比较明确的判断。如果你的计算模式是典型规则的批量通信比如有限差分法的halo交换那MPI反而非常合适因为通信结构固定、双方协调简单MPI的成熟生态和调试工具让你能少走很多弯路。PGAS更适合那些通信模式不规则、细粒度随机访问多、且需要异步重叠的场景。图遍历、稀疏矩阵向量乘、分布式哈希表、负载动态迁移类的算法用PGAS写起来会很自然而用MPI写通信逻辑非常容易把自己绕晕。如果项目是新启动且团队愿意接受新语言或新库我会优先推荐UPC或者Chapel。前者有现代C特性加持适合和GPU异构计算结合后者抽象层次高能极大减少并行代码量。如果是Fortran存量代码要并行化Coarray Fortran是成本最低的升级路径。而如果你只是想在某一个模块里用远程内存访问加速不想动整个项目OpenSHMEM是侵入性最小的选择。6.2 上手路径建议我的建议是别一开始就啃语言规范先从最小规模的示例程序跑起来。装好运行时环境写一个简单的全局数组累加跑一个多进程版本确认结果正确再看性能分数。第二步是在你自己的应用里找一个小函数模块改成PGAS版本和原来的MPI版本做对比。这个对比很重要一来验证通信收益是否真实二来让你感受远程访问和数据局部性对自己应用的影响程度。最后再逐步扩大改造范围别贪多求快PGAS的隐式远程访问一旦写混乱了排查起来比MPI还要费劲因为报错往往不会指向真正的冲突点。我自己在实际项目里最深的体会是PGAS的真正价值不在于“不用写MPI”而在于让你能用更贴近串行思维的方式思考并行算法把注意力放到数据关系和计算结构上而不是收发消息的工程细节里。但这也意味着你必须有更强的自律性时刻惦记数据归属和同步语义。把这两点拿捏住PGAS完全可以成为你的并行工具箱里一件顺手又趁手的利器。最后再分享一个小技巧调试期优先打开运行时提供的防错检查选项。很多PGAS库和编译期都有内存检测和同步检查的开关虽然会拖慢速度但能在早期把数据竞争和越界访问这种隐蔽问题抓出来等逻辑稳定了再关掉优化性能能帮你省下大把和诡异bug战斗的时间。