PGAS并行编程模型:融合共享内存与消息传递的高性能计算方案
2026/9/9 13:10:35 网站建设 项目流程

PGAS(Partitioned 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_SendMPI_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里叫threads,Coarray Fortran里叫images,Chapel里叫locales,OpenSHMEM里叫PEs(Processing 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与UPC++

3.1 UPC:C语言里长出来的PGAS

UPC(Unified 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声明全局共享对象,MYTHREADTHREADS表示当前线程号和总线程数,upc_forall可以按循环分布方式指定某个迭代应该在哪个线程上执行。它的底层靠编译器把远程访问转换成GASNet或者具体网络API的调用,所以性能比纯手写MPI要好写很多,同时保留了C语言没有虚拟机包袱的优势。

3.2 Coarray Fortran:Fortran 2008的原生选择

Fortran在科学计算领域的地位不用多说,而Coarray Fortran(CAF)是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 program

CAF的优势在于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_putshmem_getshmem_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::vector<double> > arr = { std::vector<double>(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项目侵入性小、底层控制力强代码仪式感重,全局对象表达不自然
UPC++C++库/语言侧扩展高性能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 GASNet:PGAS世界的“交通运输网”

GASNet是伯克利开发的可移植通信中间件,也是UPC、Chapel、UPC++等大量PGAS语言和框架的默认通信层。它提供两类核心机制:一类是RMA(Remote 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的很多原语直接做进了硬件。最典型的是RDMA(Remote 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战斗的时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询