3. NVSHMEM 基础通信:点对点通信与同步机制

好,咱们正式开始聊 NVSHMEM 的核心通信原语。说实话,这部分内容我反复讲过很多次,但每次备课还是会想起自己第一次踩坑的经历。嗯,咱们先从最基础的点对点通信说起。

3.1 点对点通信:nvshmem_put 与 nvshmem_get

NVSHMEM 的点对点通信,说白了就是两个 PE(处理单元)之间直接传数据。我个人习惯把 nvshmem_put 理解为“主动推送”,把 nvshmem_get 理解为“主动拉取”。

3.1.1 nvshmem_put:数据推送

nvshmem_put 的作用是把本地数据写到远端 PE 的某个地址上。它的函数签名长这样:

void nvshmem_put(void *dest, const void *source, size_t nelems, int pe);
void nvshmem_putmem(void *dest, const void *source, size_t nelems, int pe);

这里有个细节——nvshmem_put 是按类型大小传输的,而 nvshmem_putmem 是按字节数传输的。我在项目中遇到过有人把这两个搞混,结果数据量对不上,调试了一整天。

关键点:nvshmem_put 是单边通信。发送方不需要接收方配合,接收方甚至不知道自己被写了数据。这跟 MPI 的 Send/Recv 完全不一样。

3.1.2 nvshmem_get:数据拉取

反过来,nvshmem_get 是从远端 PE 读取数据到本地:

void nvshmem_get(void *dest, const void *source, size_t nelems, int pe);
void nvshmem_getmem(void *dest, const void *source, size_t nelems, int pe);

你想想看,这个操作有多方便?你不需要通知远端 PE“我要读你的数据了”,直接读就行。但这也意味着——远端 PE 必须保证数据在读取时是有效的。我曾经在项目里犯过这个错:远端数据还没写完,我就开始 get,结果读到了一堆垃圾。

避坑指南:我曾经在做一个多 GPU 的流体模拟时,用 nvshmem_get 读取邻居的边界数据。因为没加同步,每次跑出来的结果都不一样。后来加了个 barrier,问题就解决了。记住:单边通信不代表不需要同步。

3.2 阻塞与非阻塞语义

NVSHMEM 提供了两种通信模式:阻塞和非阻塞。我个人建议,初学者先用阻塞版本,等理解了数据流再切到非阻塞。

3.2.1 阻塞通信

阻塞版本的 put/get 会在操作完成后才返回。比如:

// 阻塞 put
nvshmem_put(dest, src, 100, 1);
// 到这里时,数据已经保证被写到了 PE 1 的 dest 地址上
// 但注意:这并不保证 PE 1 能看到!

嗯,这里要注意:阻塞只保证本地操作完成,不保证远端可见。这是个常见的误解。

3.2.2 非阻塞通信

非阻塞版本以 nvshmemx_ 开头,返回一个 nvshmemx_status_t 状态:

nvshmemx_status_t status;
status = nvshmemx_put(dest, src, 100, 1);
// 这里数据可能还没发出去
// 可以做点别的事
nvshmem_quiet();  // 等待所有未完成的 put/get 完成

非阻塞的好处是能实现通信和计算的重叠。我习惯这样用:先发起非阻塞通信,然后做点本地计算,最后调用 nvshmem_quiet() 等待完成。

小技巧:非阻塞 put 之后,如果你确定远端不需要立即看到数据,可以延迟调用 quiet。这样计算和通信就能重叠起来。我在做 stencil 计算时,靠这个技巧把通信开销几乎隐藏掉了。

3.3 同步机制:nvshmem_barrier 与 nvshmem_sync

同步是分布式编程里绕不开的话题。NVSHMEM 提供了两种同步方式:全局同步和集合同步。

3.3.1 nvshmem_barrier:全局屏障

nvshmem_barrier 会让所有 PE 都到达同一个点,然后才能继续往下走:

// 所有 PE 都执行到这里
nvshmem_barrier_all();
// 然后才能继续

说白了,这就是个“大家等一下”的机制。我刚开始用的时候觉得它很笨重,后来发现它其实是最安全的同步方式。

性能提示:nvshmem_barrier_all 的开销跟 PE 数量成正比。PE 越多,等待时间越长。所以能用局部同步就别用全局同步。

3.3.2 nvshmem_sync:集合同步

nvshmem_sync 是更灵活的同步方式。它只同步指定的 PE 集合:

// 只同步 PE 0, 1, 2
int pe_list[3] = {0, 1, 2};
nvshmem_sync(pe_list, 3);

这个函数我在做子区域通信时特别喜欢用。比如一个 4x4 的网格,我只想同步某一行上的 PE,用 nvshmem_sync 就比全局 barrier 高效得多。

注意:nvshmem_sync 要求所有参与同步的 PE 都调用它。如果有 PE 没调用,那调用的 PE 就会一直等下去。我曾经在调试时遇到过这种死锁,排查了半天才发现是某个 PE 的同步调用被条件判断跳过了。

3.4 知识体系图

下面这张图总结了本章的核心内容,我建议你把它记在脑子里:

NVSHMEM 基础通信体系 点对点通信 nvshmem_put(推送) nvshmem_get(拉取) 单边通信,无需接收方配合 阻塞:操作完成后返回 同步机制 nvshmem_barrier(全局屏障) nvshmem_sync(集合同步) 确保数据可见性 局部同步比全局同步更高效 非阻塞通信 nvshmemx_put / nvshmemx_get 返回状态码,不等待完成 需要 nvshmem_quiet() 等待 通信与计算重叠的关键 实践建议 先阻塞后非阻塞 注意数据可见性 避免死锁 加同步,保正确 核心原则:单边通信 + 显式同步 = 正确且高效的分布式程序

3.5 本章小结

咱们这一章聊了 NVSHMEM 最基础的通信原语。说白了就是三件事:怎么发数据(put/get)、怎么控制时机(阻塞/非阻塞)、怎么保证正确(同步)。

我个人觉得,理解单边通信的“不配合”特性是最关键的。你想想看,在 MPI 里你发数据必须有人收,但在 NVSHMEM 里你可以直接写别人的内存。这既是优势也是陷阱——优势是灵活,陷阱是容易忘记同步。

嗯,最后再啰嗦一句:刚开始用 NVSHMEM 时,先加 barrier 保证正确,再优化性能。这是我踩过无数坑之后总结出来的经验。