CS267 · 第 11 课

UPC++ 分区全局地址空间

PGAS 模型如何通过 one-sided 通信与 RDMA 实现高性能并行编程

授课:Kathy Yelick 视频:YouTube 预计用时:75 分钟

动机:介于共享内存与分布式内存之间

大规模并行系统上的编程模型长期面临两难选择:共享内存模型(如 OpenMP)编程简便但不易扩展;分布式内存模型(如 MPI)扩展性好但编程复杂,需要显式管理消息收发。分区全局地址空间(Partitioned Global Address Space, PGAS)模型试图在两者之间取得平衡。

PGAS 核心思想:内存逻辑上全局可见(像共享内存),但物理上分布到各个节点,并通过 affinity(亲和性)标注数据归哪个进程所有。程序员既能直接访问远程数据,又能利用局部性优化性能。

PGAS 语言/库包括:UPC、UPC++、Chapel、X10、Fortran 2008 coarrays、SHMEM。本课程聚焦 UPC++,它是现代 C++ 的 PGAS 库,无需编译器改造即可使用。


One-Sided 通信与 RDMA

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++ 设计

UPC++ 是一个基于 C++ 模板库的 PGAS 编程框架,其关键设计决策:

// 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 与 Affinity

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();
}

异步操作:rget、rput 与 copy

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 可以摊销每次操作的固定开销,提高带宽利用率。


Futures 与异步执行

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;
    });

全局同步:Barriers

UPC++ 提供多种 barrier 变体以适应不同场景:

// 典型计算-通信-同步循环
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();
}

原子操作:atomic_domain

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);

原子操作注意事项:


集合通信(Collectives)

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

远程过程调用(RPC)

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:


分布式对象

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];
        });
    }
};

回调与 then/when_all

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 仍在传输中。


Progress 与通信推进

UPC++ 的异步操作需要显式推进(progress)。通信不会自动在后台完成,必须通过以下方式之一触发:

// 手动推进通信(在长时间计算中)
upcxx::future<> fut = upcxx::rget(remote, buf, n);

// 计算与通信重叠
while (!fut.ready()) {
    do_partial_work();
    upcxx::progress();  // 推进通信
}
fut.wait();  // 确保完成

Progress 陷阱:如果在发起 rget 后长时间不调用 progress 或 wait,通信将无法完成。这可能导致程序挂起或性能下降。


案例研究

MetaHipMer:基因组组装

MetaHipMer 是用于宏基因组组装的 UPC++ 应用。基因组组装需要处理 TB 级的测序数据,涉及大量不规则通信。UPC++ 的 one-sided 通信和原子操作使其能够高效实现 de Bruijn 图遍历。

SIMCoV:COVID-19 模拟

SIMCoV 使用 UPC++ 实现大规模细胞自动机模拟,模拟 COVID-19 在人群中的传播。每个细胞的状态通过 RDMA 与邻居交换。

SymPACK:稀疏直接求解器

SymPACK 是稀疏对称不定矩阵的直接求解器。稀疏矩阵的不规则结构天然适合 UPC++ 的异步 one-sided 通信模式。

不规则应用的优势:相比规则网格或稠密线性代数,不规则应用(图、稀疏矩阵、粒子方法)更能从 UPC++ 的 one-sided 通信中获益,因为 MPI 的 two-sided 模型在这些场景下难以高效表达。


本课要点

PGAS 模型

在共享内存的编程简便性与分布式内存的可扩展性之间取得平衡,通过 affinity 标注数据归属。

One-Sided 通信

利用 RDMA 实现 CPU 旁路的远程内存访问,天然支持异步执行与延迟隐藏。

UPC++ 抽象

global_ptr、atomic_domain、dist_object、rpc 等高层抽象降低 PGAS 编程复杂度。

异步与 Futures

所有异步操作返回 future,支持 wait/then/when_all 等组合,实现通信-计算重叠。

Progress 模型

通信推进需要显式触发,程序员需在计算与 progress 调用之间取得平衡。

不规则应用

UPC++ 在图分析、稀疏求解、粒子模拟等不规则应用中表现尤为突出。


知识检查

问题 1:PGAS 模型中,内存是全局可见的吗?

问题 2:UPC++ 的 one-sided 通信相比 MPI 的 send/recv 主要优势是什么?

问题 3:在 UPC++ 中,rget 和 rput 的方向分别是?

问题 4:UPC++ 的异步通信如何推进?

问题 5:当需要从远端读取一个 1MB 的数组时,应该使用 rget 还是 RPC?