GPU 点对点通信

单机内,两张 GPU 怎样交换数据

核心解释

GPU A 算出的结果放在自己的显存里,GPU B 接下来需要使用它。两张卡怎样找到这块数据、把数据传过去,又怎样知道可以开始计算?这就是 GPU 点对点通信要解决的问题。下面以 NVIDIA GPU 和 CUDA 为例,沿一次数据交接理解单机内的通信。

先有通路,再有访问权限

数据通过 PCIe 或 NVLink 在两张 GPU 之间传输。PCIe 路径可能经过交换芯片或 CPU 的根复合体。NVLink 可以直接连接 GPU,也可以经过 NVSwitch 转发。P2P 让 GPU 直接访问对端显存,或在两端显存之间复制数据,不必先在主机内存中存一份。能否使用这条路径,取决于两张卡、机器拓扑和驱动支持。[1] [2]

硬件连通之后,还要让发起访问的 GPU 识别目标地址。指针是虚拟地址,地址映射把它关联到实际的显存,访问权限决定哪张 GPU 可以读写。在同一进程中,可以查询 P2P 能力,再开启对端访问。虚拟内存管理(VMM)允许程序分别管理显存分配、地址映射与访问权限,按内存区域授权。映射建立后,数据仍在原来的显存中。[3] [4]

如果两张 GPU 由不同进程控制,还要跨过进程的地址空间边界。分配方导出显存句柄,另一进程导入后取得自己能使用的指针。CUDA 的进程间通信接口(IPC)和 VMM 都提供了相应机制。交换句柄完成的是连接准备,后续读写才真正传输数据。[5]

复制一份,或直接读写对方

一种方式是把 A 的数据复制到 B 的显存,B 随后使用本地副本。CUDA 提供 GPU 间的异步拷贝接口,硬件可通过专用拷贝引擎(CE)执行批量传输。这样可以把数据搬运与计算分开安排。是否实际重叠,要看依赖关系和硬件资源。[2]

另一种方式是让 GPU 的流式多处理器(SM)执行 kernel,直接读写对端显存。Kernel 是运行在 GPU 上的计算函数。例如,B 的 kernel 读取 A 的数组,把结果写入 B 的数组。也可以由 A 的 kernel 向 B 的缓冲区写入数据。这里的远端 load/store 仍要经过互连。它省去的是预先准备整个本地副本的步骤,并没有省掉数据传输。官方 simpleP2P 示例同时展示了跨卡复制和 kernel 读取对端数据。[3]

最后把数据交接完整

地址可访问之后,程序还需要约定操作顺序。以复制到 B 为例,A 要先完成生产,拷贝才能开始。拷贝完成后,B 才能消费。相关访问结束后,缓冲区才能被覆盖或释放。CPU 提交异步操作后返回,只说明工作已经提交。可以用执行队列(stream)和事件(event)表达这些依赖,CUDA 也允许一个 GPU 的 stream 等待另一个 GPU 的 event。[2] [6]

A 完成生产跨卡复制完成B 完成消费复用目标缓冲区

如果两个 GPU 的 kernel 同时运行,并通过显存中的标志交接数据,就需要跨设备的内存同步语义。发送方先发布数据,再发布就绪状态。接收方确认状态后读取数据。具体实现要同时满足原子操作能力、覆盖两张卡的系统作用域(system scope)和内存顺序要求。只建立映射,或只把标志改成原子变量,都不足以自动完成这套交接。[7]

最后再测这条路径的实际表现。小数据更容易受启动和同步延迟影响,大块连续数据更适合观察持续带宽。拷贝引擎与 kernel 搬运、单向与双向、不同 GPU 配对,应分别测量。nvbandwidth 提供这些基础测试。早期互连测量论文则展示了拓扑和数据规模怎样改变结果。[8] [9]

资料

QA

没有 NVLink,两张 GPU 还能直接通信吗?

可以,支持 P2P 的设备对也能通过 PCIe 直接传输。PCIe 交换芯片、根复合体、设备与驱动共同决定可用路径。“经过 CPU 的根复合体”与“把数据复制到主机内存中转”是两件事。

先查两张卡之间的访问能力。如果 P2P 不可用,应用仍可明确安排 GPU A → 主机缓冲区 → GPU B 的复制。此时多了一次主机内存中转,GPU kernel 也不能直接解引用一个未经支持和映射的对端指针。

开启 A 对 B 的访问,B 就能访问 A 吗?

权限是有方向的。把 A 设为当前设备后,cudaDeviceEnablePeerAccess(B, 0) 允许 A 访问 B 的显存。B 访问 A 要另行配置,能力查询也要按方向检查。

传统 peer enable 会影响相应的 cudaMalloc 分配。VMM 可以把权限限定到选定的映射区域,并指定能访问它的设备。权限控制的是谁能访问内存,不代表数据已经准备完成。

统一地址空间是不是把两张卡的显存合成一块了?

UVA 统一的是一个进程中的虚拟地址布局。两个 cudaMalloc 分配仍分别驻留在各自的 GPU 上,远端访问仍要通过互连,并满足对端访问条件。

Unified Memory 是另一套内存管理机制,可以管理数据迁移或访问位置。VMM 则把分配、地址保留、映射和权限拆开交给程序控制。三者都不能仅凭“指针可用”推导出访问具有本地显存的性能。

复制到本地,与直接读取对端显存,怎么选?

如果 B 要多次使用同一批数据,先复制到 B 的显存,可以让后续访问留在本地。如果只读一次或只访问少量元素,kernel 直接读取对端数据可以减少准备完整副本的工作。具体收益取决于访问是否连续、数据复用和两张卡之间的通路。

远端读取会返回数据,远端写入会把数据送到目标显存。它们都产生互连流量。还要比较搬运占用的 SM、拷贝引擎,以及它们与计算的竞争,不能只比较拷贝 API 的调用次数。

跨进程通信时,Socket 要一直转发 GPU 数据吗?

在 IPC 或 VMM 映射方案中,Socket 可以只负责交换建立映射所需的信息。接收进程导入显存句柄后,GPU 数据走设备间通路。把指针数值直接发给另一个进程,不能代替导入和映射。

句柄怎样传递取决于接口。传统 CUDA IPC 的句柄可以作为字节数据交换。Linux 上 VMM 导出的 POSIX 文件描述符要通过支持传递文件描述符的机制发送,例如 Unix domain socket。只发送那个整数值不会在另一个进程里创建有效描述符。

异步复制函数返回后,另一张 GPU 可以马上使用数据吗?

要先建立完成依赖。例如 A 的生产 stream 记录 ready 事件,拷贝 stream 等待它,再执行复制并记录 copied 事件。B 的消费 stream 等待 copied 后再启动 kernel。事件要由所属设备上的 stream 记录,cudaStreamWaitEvent 可以跨设备等待。

主机侧也要保证相应的事件记录已经提交,再提交针对这次工作的等待。跨进程还需要导入可共享的事件。simpleIPC 用进程屏障协调事件记录与等待的提交顺序。

数据复制完以后,哪些内存可以覆盖或释放?

对于普通复制,源缓冲区可以在拷贝不再读取它之后复用。目标缓冲区要等消费方不再使用它。对于 kernel 直接读取对端显存,源分配必须保留到这些远端读取完成。

跨进程还要处理映射生命周期。传统 CUDA IPC 要协调导入方关闭映射与分配方释放内存。VMM 使用引用管理物理分配:释放一个句柄不等于立即释放仍被映射引用的内存,最终还要解除映射。清理动作之前,程序仍需等待使用它的 GPU 工作完成。

flag、head 这类变量在解决什么问题?

它们通常记录交接进度,但名字本身没有固定的 CUDA 语义。就绪标志可以表示某批数据已经写好,消费位置可以表示哪些缓冲区已经读完、允许再次写入。发布数据与回收空间,是两个不同的时机。

若用 GPU 线程轮询这些变量,必须同时保证状态更新的可见性和数据访问的顺序。需要结合相应硬件能力、原子作用域和 release/acquire 等语义实现。普通变量赋值并不会自动同步另一张 GPU。

能读写对端显存,就能在上面执行 atomicAdd 吗?

读写能力与原子能力分别查询。除了 cudaDeviceCanAccessPeer,还应核对设备对的原子属性。当前 CUDA 提供 cudaDeviceGetP2PAtomicCapabilities,可进一步查询具体原子操作的支持情况。操作类型与数据类型也要匹配。

对共享计数器的跨 GPU 更新,还需要覆盖两张卡的作用域。原子加解决更新不可分割的问题。若用计数器表示别处的数据已准备好,还需正确的内存顺序,才能让数据写入与状态通知建立关系。

PCIe P2P 显示 NS,能说明两张卡不能直接通信吗?

先看查询的是哪种能力。nvidia-smi topo -p2p 的 r、w、n、a、p 分别查询读、写、NVLink、原子和 PCIe P2P。PCIe 一项未支持,不能替代对 NVLink 路径和当前设备对读写能力的检查。

同样,在 NVLink 路径上查到原子能力,也不能据此证明 PCIe 路径支持同样的原子操作。判断一对 GPU 时,结合 topo -m 的拓扑、分项能力查询和实际读写验证。设备规格中的 PCIe AtomicOps 字段回答的是更具体的硬件能力问题。

怎样测到传输速度,而不是只测到提交速度?

先明确测量范围。测稳定搬运速度时,提前完成分配、映射和预热。测一次完整交接时,则把相应的等待与同步也算进去。计时结束点必须覆盖设备工作完成。两个不同 GPU 上记录的事件,不能直接传给 cudaEventElapsedTime 相减。

有效带宽等于实际搬运字节数除以完成耗时。双向测试若累加两个方向的字节数,应与同口径结果比较。用 nvbandwidth 固定 GPU 配对、数据规模和重复次数,分别看 CE 与 SM、读与写,再逐步改变数据大小。

Beffective=StransferredTcompletedB_{\mathrm{effective}}=\frac{S_{\mathrm{transferred}}}{T_{\mathrm{completed}}}