PGAS 模型
在共享内存的编程简便性与分布式内存的可扩展性之间取得平衡,通过 affinity 标注数据归属。
CS267 · 第 11 课
PGAS 模型如何通过 one-sided 通信与 RDMA 实现高性能并行编程
大规模并行系统上的编程模型长期面临两难选择:共享内存模型(如 OpenMP)编程简便但不易扩展;分布式内存模型(如 MPI)扩展性好但编程复杂,需要显式管理消息收发。分区全局地址空间(Partitioned Global Address Space, PGAS)模型试图在两者之间取得平衡。
PGAS 核心思想:内存逻辑上全局可见(像共享内存),但物理上分布到各个节点,并通过 affinity(亲和性)标注数据归哪个进程所有。程序员既能直接访问远程数据,又能利用局部性优化性能。
PGAS 语言/库包括:UPC、UPC++、Chapel、X10、Fortran 2008 coarrays、SHMEM。本课程聚焦 UPC++,它是现代 C++ 的 PGAS 库,无需编译器改造即可使用。
MPI 主要依赖 two-sided 通信(send/recv):发送方和接收方必须协同参与,接收方必须显式匹配接收。这带来同步开销和延迟。
One-sided 通信(远程内存访问,Remote Direct Memory Access)允许一个进程直接读写另一个进程的内存,无需远端 CPU 参与。现代高速网络(InfiniBand、Cray Slingshot)提供硬件级 RDMA 支持。
| 特性 | Two-sided (MPI) | One-sided (UPC++/RDMA) |
|---|---|---|
| 远端 CPU 参与 | 必须 | 不需要(硬件直传) |
| 同步点 | 发送与接收匹配 | 发起即异步 |
| 延迟隐藏 | 困难 | 易与计算重叠 |
| 编程复杂度 | 中等 | 较低 |
| 不规则通信 | 不自然 | 天然支持 |
RDMA 优势:数据直接在网卡与内存之间传输,绕过远端 CPU。这使得 UPC++ 可以在计算的同时异步推进通信,有效隐藏网络延迟。
UPC++ 是一个基于 C++ 模板库的 PGAS 编程框架,其关键设计决策:
upcxx::rank_me() 区分global_ptr<T> 封装分布式内存地址// UPC++ 程序骨架
#include <upcxx/upcxx.hpp>
int main(int argc, char** argv) {
upcxx::init();
int rank = upcxx::rank_me();
int nprocs = upcxx::rank_n();
// 在每个 rank 上分配一个整数
upcxx::global_ptr<int> ptr = upcxx::new_<int>(42);
// 获取 rank 0 上的指针值(仅 rank 0 执行)
if (rank == 0) {
upcxx::global_ptr<int> remote = ptr;
// rget/rput 异步操作见下文
}
upcxx::finalize();
return 0;
}
global_ptr<T> 是 UPC++ 的核心抽象。它包含两个关键信息:
注意:global_ptr 不能直接解引用!必须通过 rget/rput 或 copy 进行远程访问。这是 UPC++ 与 UPC 的重要区别。
Affinity 决定了数据物理位置,是性能优化的关键。程序员应尽量让计算靠近其所需数据:
// 检查指针的 affinity
upcxx::global_ptr<int> gp = /* ... */;
int owner = gp.where(); // 返回拥有该内存的 rank
// 本地 vs 远程判断
if (gp.where() == upcxx::rank_me()) {
// 可以直接解引用本地副本
int val = *gp.local();
}
UPC++ 提供三种主要的远程内存访问原语:
| 操作 | 方向 | 语义 |
|---|---|---|
rget(src) |
远端 → 本地 | 异步读取远端内存到本地 buffer |
rput(dst, src) |
本地 → 远端 | 异步写入本地数据到远端 |
copy(src, dst) |
通用 | 更灵活的拷贝(UPC++ 1.1+) |
// rget 示例:从 rank 0 读取一个数组
if (rank == 1) {
upcxx::global_ptr<int> remote = /* rank 0 上的指针 */;
std::vector<int> local_buf(100);
upcxx::future<> fut = upcxx::rget(remote, local_buf.data(), local_buf.size());
fut.wait(); // 等待完成
// local_buf 现在包含远端数据
}
// rput 示例:向 rank 0 写入数据
if (rank == 1) {
upcxx::global_ptr<int> remote = /* rank 0 上的指针 */;
std::vector<int> data = {1, 2, 3, 4, 5};
upcxx::future<> fut = upcxx::rput(data.data(), remote, data.size());
fut.wait();
}
性能提示:尽量批量传输(bulk transfer)而非频繁小操作。使用更大的 buffer 可以摊销每次操作的固定开销,提高带宽利用率。
UPC++ 的异步操作返回 upcxx::future<> 对象,允许程序员显式控制同步时机:
// 发起多个异步操作,然后统一等待
std::vector<upcxx::future<>> futures;
for (int i = 0; i < neighbors.size(); i++) {
futures.push_back(upcxx::rget(remote_ptrs[i], local_bufs[i], sizes[i]));
}
// 计算可以与通信重叠...
do_some_work();
// 等待所有通信完成
upcxx::wait_all(futures.begin(), futures.end());
Futures 还支持链式回调(continuation),当操作完成时自动触发后续代码:
// then 回调:操作完成后执行
upcxx::rget(remote_ptr, local_buf, n)
.then([]() {
std::cout << "rget 完成!" << std::endl;
});
// when_all:等待多个 future 全部完成
upcxx::when_all(fut1, fut2, fut3)
.then([]() {
std::cout << "所有操作完成" << std::endl;
});
UPC++ 提供多种 barrier 变体以适应不同场景:
upcxx::barrier():全局 barrier,所有 rank 必须到达upcxx::barrier(upcxx::future<>&):异步 barrier,返回 futureupcxx::quiesce():等待所有未完成的异步操作完成// 典型计算-通信-同步循环
for (int iter = 0; iter < max_iter; iter++) {
// 1. 发起 halo 交换
exchange_halo_data();
// 2. 计算内部区域(不依赖 halo)
compute_interior();
// 3. 等待 halo 交换完成
upcxx::barrier();
// 4. 计算边界区域(依赖 halo)
compute_boundary();
}
UPC++ 提供通过 atomic_domain 实现的远程原子操作。所有原子操作必须绑定到一个 atomic_domain 对象上,该对象封装了内存顺序语义。
// 创建 atomic_domain,指定支持的内存顺序
upcxx::atomic_domain<int> ad({upcxx::atomic_op::fetch_add,
upcxx::atomic_op::compare_swap});
// 在远端执行 fetch-and-add
upcxx::global_ptr<int> counter = /* ... */;
upcxx::future<int> old_val = ad.fetch_add(counter, 1, upcxx::memory_order_relaxed);
// compare-and-swap
upcxx::future<bool> success = ad.compare_swap(counter, expected, desired);
原子操作注意事项:
UPC++ 支持常见的集合通信操作,与 MPI 类似但返回 future:
| 操作 | 功能 | 返回值 |
|---|---|---|
broadcast(ptr, root) |
root 广播数据到所有 rank | future |
reduce(ptr, op, root) |
归约操作(sum/max/min 等) | future(root 获得结果) |
allgather |
全收集 | future |
// 广播示例
upcxx::global_ptr<int> data = /* rank 0 上的数据 */;
upcxx::broadcast(data, 1, 0).wait(); // 所有 rank 获得副本
// 归约示例
upcxx::global_ptr<int> local_sum = /* 每个 rank 的局部和 */;
upcxx::future<int> total = upcxx::reduce(local_sum, upcxx::operation::plus, 0);
// rank 0 上获得 total
UPC++ 支持 rpc 原语,允许一个 rank 在另一个 rank 上执行函数。这比 rget/rput 更灵活,适合不规则通信模式。
// 在 rank target_rank 上执行函数
int compute(int x, int y) { return x * y + 1; }
// 发起 RPC
upcxx::future<int> result = upcxx::rpc(target_rank, [](int a, int b) {
return a + b;
}, 3, 4);
int val = result.wait(); // val = 7
// 带返回值的 RPC
upcxx::future<std::string> name = upcxx::rpc(0, []() {
return std::string("Hello from rank " + std::to_string(upcxx::rank_me()));
});
RPC vs rget/rput:
rget/rput:适合批量数据搬运,带宽高rpc:适合不规则、小数据量的远程操作,灵活但延迟高UPC++ 提供 dist_object 抽象,将数据分布到所有 rank,每个 rank 持有局部视图:
// 创建一个分布式数组
upcxx::dist_object<upcxx::global_ptr<int>> dist_arr(upcxx::new_array<int>(100));
// 每个 rank 可以访问其他 rank 的视图
int neighbor = (rank + 1) % nprocs;
upcxx::global_ptr<int> neighbor_ptr = dist_arr.fetch(neighbor).wait();
// 现在可以 rget/rput 邻居的数据
分布式哈希表(DHT)是展示 UPC++ 能力的经典示例。每个 rank 负责一部分哈希桶,通过 RPC 实现跨 rank 的插入和查找:
// 简化的 DHT 实现
class DistributedHashTable {
std::unordered_map<int, std::string> local_table;
public:
// 计算 key 属于哪个 rank
int owner(int key) const {
return key % upcxx::rank_n();
}
// 插入操作:RPC 到 owner
upcxx::future<void> insert(int key, const std::string& value) {
int target = owner(key);
if (target == upcxx::rank_me()) {
local_table[key] = value;
return upcxx::make_future();
}
return upcxx::rpc(target, [key, value]() {
// 在 target rank 上执行
local_table[key] = value;
});
}
// 查找操作
upcxx::future<std::string> lookup(int key) {
int target = owner(key);
if (target == upcxx::rank_me()) {
return upcxx::make_future(local_table[key]);
}
return upcxx::rpc(target, [key]() {
return local_table[key];
});
}
};
UPC++ 的 future 机制支持丰富的回调组合:
// then:链式回调
upcxx::rget(remote, buf, n)
.then([buf]() {
process(buf);
})
.then([]() {
finalize();
});
// when_all:等待多个独立操作
auto f1 = upcxx::rget(src1, dst1, n1);
auto f2 = upcxx::rget(src2, dst2, n2);
upcxx::when_all(f1, f2).then([]() {
// 两个 rget 都完成后再执行
compute(dst1, dst2);
});
// when_any:任一操作完成即触发
upcxx::when_any(f1, f2).then([]() {
// 至少一个完成
});
回调设计模式:使用 then 可以将通信与计算流水线化。当第一个 rget 完成时就开始处理其数据,同时第二个 rget 仍在传输中。
UPC++ 的异步操作需要显式推进(progress)。通信不会自动在后台完成,必须通过以下方式之一触发:
upcxx::progress():手动推进future.wait():等待特定操作完成时推进upcxx::barrier():集合操作会推进upcxx::discharge():排空所有未完成操作// 手动推进通信(在长时间计算中)
upcxx::future<> fut = upcxx::rget(remote, buf, n);
// 计算与通信重叠
while (!fut.ready()) {
do_partial_work();
upcxx::progress(); // 推进通信
}
fut.wait(); // 确保完成
Progress 陷阱:如果在发起 rget 后长时间不调用 progress 或 wait,通信将无法完成。这可能导致程序挂起或性能下降。
MetaHipMer 是用于宏基因组组装的 UPC++ 应用。基因组组装需要处理 TB 级的测序数据,涉及大量不规则通信。UPC++ 的 one-sided 通信和原子操作使其能够高效实现 de Bruijn 图遍历。
SIMCoV 使用 UPC++ 实现大规模细胞自动机模拟,模拟 COVID-19 在人群中的传播。每个细胞的状态通过 RDMA 与邻居交换。
SymPACK 是稀疏对称不定矩阵的直接求解器。稀疏矩阵的不规则结构天然适合 UPC++ 的异步 one-sided 通信模式。
不规则应用的优势:相比规则网格或稠密线性代数,不规则应用(图、稀疏矩阵、粒子方法)更能从 UPC++ 的 one-sided 通信中获益,因为 MPI 的 two-sided 模型在这些场景下难以高效表达。
在共享内存的编程简便性与分布式内存的可扩展性之间取得平衡,通过 affinity 标注数据归属。
利用 RDMA 实现 CPU 旁路的远程内存访问,天然支持异步执行与延迟隐藏。
global_ptr、atomic_domain、dist_object、rpc 等高层抽象降低 PGAS 编程复杂度。
所有异步操作返回 future,支持 wait/then/when_all 等组合,实现通信-计算重叠。
通信推进需要显式触发,程序员需在计算与 progress 调用之间取得平衡。
UPC++ 在图分析、稀疏求解、粒子模拟等不规则应用中表现尤为突出。
问题 1:PGAS 模型中,内存是全局可见的吗?
问题 2:UPC++ 的 one-sided 通信相比 MPI 的 send/recv 主要优势是什么?
问题 3:在 UPC++ 中,rget 和 rput 的方向分别是?
问题 4:UPC++ 的异步通信如何推进?
问题 5:当需要从远端读取一个 1MB 的数组时,应该使用 rget 还是 RPC?