CUDA 跨进程共享显存
在单机多 GPU 程序中,不同进程可以把一块 GPU Device Memory 映射到各自的 CUDA 虚拟地址空间,再由 Kernel 通过普通指针直接访问。NVIDIA 提供两类进程间显存共享接口:Legacy CUDA IPC 和 VMM-based IPC。前者把 cudaMalloc Allocation 作为整体导出,后者使用 CUDA Virtual Memory Management(VMM)接口,把物理 Allocation、虚拟地址和访问权限拆开管理。
相关术语定义如下:
- Legacy CUDA IPC:
cudaIpcGetMemHandle、cudaIpcOpenMemHandle及对应的cuIpc*Memory API;同一 API 家族还提供 IPC Event。 - VMM-based IPC:特指
cuMemExportToShareableHandle、cuMemImportFromShareableHandle和cuMemMap等 VMM Driver API 组成的共享路径。 - CUDA 进程间通信:Legacy CUDA IPC、VMM-based IPC 等进程间 CUDA 资源共享机制的总称。
跨进程共享显存不等同于 CUDA C++ 的 __shared__ Memory:
| 名称 | 所在位置 | 可见范围 | 主要用途 |
|---|---|---|---|
| CUDA Shared Memory | SM 片上存储 | 同一 Thread Block | Block 内线程协作 |
| 跨进程共享 Device Memory | HBM/GDDR,经 L2 和 GPU Interconnect 访问 | 建立映射的 CUDA Context | GPU 间或进程间交换数据 |
以下内容限定为 Linux 单机进程间共享。跨节点通信还需要 GPUDirect RDMA、NVSHMEM、NCCL 或 CUDA Fabric Handle 等机制。
1. 地址映射与数据传输
CUDA Device Pointer 是当前进程 CUDA 虚拟地址空间中的地址,不是可以直接发送给其他进程使用的物理地址:
Process 0 / CUDA Context 0
pointer_a ──VA Mapping──┐
├── GPU 0 Physical Allocation
Process 1 / CUDA Context 1│
pointer_b ──VA Mapping──┘pointer_a 与 pointer_b 的数值可以不同,但它们映射到同一个底层 Allocation。Process 1 的 Kernel 对 pointer_b 执行 Load/Store 时,数据根据设备拓扑经过 NVLink、NVSwitch 或 PCIe 到达 GPU 0 显存。
因此,跨进程共享显存需要区分三层:
Allocation 与地址映射
Legacy CUDA IPC 或 VMM-based IPC
↓ 提供可解引用的 Peer Pointer
数据访问
Kernel Load/Store、Memcpy 或通信 Kernel
↓ 经过物理链路
同步与可见性
CUDA Event、Stream Dependency、Barrier 或 CPU 控制通道Legacy CUDA IPC 与 VMM-based IPC 只建立映射,不会自动复制数据,也不定义 All-Reduce、All-Gather 等通信语义。应用仍要决定谁读、谁写、何时可以复用缓冲区。
2. 使用条件
Legacy CUDA IPC 和单机 VMM-based IPC 通常需要满足以下条件:
- 进程运行在同一台 Linux 主机上,并由兼容的 NVIDIA Driver 管理。
- GPU 支持 Unified Virtual Addressing;VMM 还要求
CU_DEVICE_ATTRIBUTE_VIRTUAL_ADDRESS_MANAGEMENT_SUPPORTED。 - 跨 GPU 直接访问前,使用
cudaDeviceCanAccessPeer或cuDeviceCanAccessPeer检查拓扑是否支持 P2P。 - Exporter 在所有 Importer 释放映射前保持原始 Allocation 存活。
- 程序在
fork()前不要初始化 CUDA。更常见的生产方式是直接启动独立 Worker 进程,再通过 Unix Domain Socket 或进程组交换 Handle。
即使 P2P 可用,链路也不一定是 NVLink。PCIe P2P 与 NVLink 的带宽、延迟和原子操作能力不同,应通过 nvidia-smi topo -m 和实际 Profile 确认物理路径。
3. Legacy CUDA IPC
Legacy CUDA IPC 通常使用 CUDA Runtime API,把 cudaMalloc 创建的完整 Device Allocation 导出为 cudaIpcMemHandle_t:
Exporter Importer
cudaMalloc 接收 cudaIpcMemHandle_t
↓ ↓
cudaIpcGetMemHandle cudaIpcOpenMemHandle
↓ ↓
通过 Unix Socket/Shared Memory 发送 Handle 获得本进程 Peer Pointer
↓
Kernel 直接访问远端显存
↓
cudaIpcCloseMemHandle
↓
等待 Importer 完成后 cudaFree3.1 Exporter
Exporter 在 GPU 0 分配显存、完成初始化并导出 Handle。send_bytes 代表任意可靠的进程间控制通道;它传递的只是 Handle 和元数据,不传递 Device Memory 内容。
#include <cuda_runtime.h>
#include <cstddef>
struct IpcAllocationDesc {
cudaIpcMemHandle_t handle;
size_t bytes;
};
IpcAllocationDesc export_allocation(void** pointer, size_t bytes) {
cudaSetDevice(0);
cudaMalloc(pointer, bytes);
// 在导出前完成需要对 Importer 可见的初始化。
cudaMemset(*pointer, 0, bytes);
cudaDeviceSynchronize();
IpcAllocationDesc desc{};
desc.bytes = bytes;
cudaIpcGetMemHandle(&desc.handle, *pointer);
return desc;
}
void exporter(int control_socket) {
void* owner_pointer = nullptr;
IpcAllocationDesc desc =
export_allocation(&owner_pointer, 4 * 1024 * 1024);
send_bytes(control_socket, &desc, sizeof(desc));
// 等待 Importer 完成 Kernel 并关闭映射。
wait_done(control_socket);
cudaFree(owner_pointer);
}cudaIpcGetMemHandle 要求传入 Allocation Base Pointer。若 Tensor 只是较大 Allocation 中的一个切片,应额外发送相对 Base 的 Offset,由 Importer 在打开 Base 后计算真实地址:
void* tensor_pointer = static_cast<char*>(base_pointer) + tensor_offset;3.2 Importer
Importer 在 GPU 1 上打开 Handle。返回的 peer_pointer 属于 Importer 的 CUDA 地址空间,但底层仍是 Exporter 在 GPU 0 上创建的物理 Allocation。
__global__ void add_one(float* data, size_t count) {
size_t index = blockIdx.x * blockDim.x + threadIdx.x;
if (index < count) {
data[index] += 1.0f;
}
}
void importer(int control_socket) {
IpcAllocationDesc desc{};
recv_bytes(control_socket, &desc, sizeof(desc));
cudaSetDevice(1);
int can_access = 0;
cudaDeviceCanAccessPeer(&can_access, 1, 0);
if (!can_access) {
throw std::runtime_error("GPU 1 cannot access GPU 0 through P2P");
}
void* peer_pointer = nullptr;
cudaIpcOpenMemHandle(
&peer_pointer,
desc.handle,
cudaIpcMemLazyEnablePeerAccess);
size_t count = desc.bytes / sizeof(float);
add_one<<<(count + 255) / 256, 256>>>(
static_cast<float*>(peer_pointer), count);
cudaDeviceSynchronize();
// Open 得到的 Pointer 必须 Close,不能 cudaFree。
cudaIpcCloseMemHandle(peer_pointer);
notify_done(control_socket);
}cudaIpcMemLazyEnablePeerAccess 允许 CUDA 在打开 Handle 时按需建立设备间 Peer Access。Importer 不应假设打开后的指针与 Exporter 原始指针数值相同。
3.3 Legacy CUDA IPC Event
内存映射本身不表示数据已经生产完成。一次性生产者—消费者流程可以额外共享 CUDA Event:
// Exporter
cudaEvent_t ready;
cudaEventCreateWithFlags(
&ready,
cudaEventDisableTiming | cudaEventInterprocess);
produce<<<grid, block, 0, producer_stream>>>(owner_pointer);
cudaEventRecord(ready, producer_stream);
cudaIpcEventHandle_t event_handle;
cudaIpcGetEventHandle(&event_handle, ready);
send_bytes(control_socket, &event_handle, sizeof(event_handle));Importer 打开 Event,并让消费 Stream 等待生产 Stream:
// Importer
cudaIpcEventHandle_t event_handle;
recv_bytes(control_socket, &event_handle, sizeof(event_handle));
cudaEvent_t ready;
cudaIpcOpenEventHandle(&ready, event_handle);
cudaStreamWaitEvent(consumer_stream, ready, 0);
consume<<<grid, block, 0, consumer_stream>>>(peer_pointer);共享 Event 适合表达 Stream 间依赖,但循环通信仍需设计 Buffer Slot、Epoch 或双向完成通知,避免生产者覆盖消费者尚未读完的数据。
4. VMM-based IPC
VMM 使用 CUDA Driver API,把传统 cudaMalloc 隐含完成的操作拆成四步:
cuMemCreate 创建物理 Allocation,尚无可访问 Pointer
cuMemAddressReserve 保留一段 GPU 虚拟地址
cuMemMap 建立 VA → Physical Allocation 映射
cuMemSetAccess 设置哪些 GPU 可以读写这段映射同一物理 Allocation 可以被导出到其他进程,再分别映射到各自的虚拟地址空间:
Exporter Importer
cuMemCreate 接收 POSIX fd
↓ ↓
cuMemAddressReserve cuMemImportFromShareableHandle
↓ ↓
cuMemMap cuMemAddressReserve
↓ ↓
cuMemSetAccess cuMemMap
↓ ↓
cuMemExportToShareableHandle cuMemSetAccess
↓ ↓
SCM_RIGHTS 发送 POSIX fd 获得可访问的 Peer VA4.1 创建可导出的 VMM Allocation
cuMemCreate 的 Size 必须按设备要求的 Granularity 向上对齐。VMM API 中的 CU_MEM_ALLOCATION_TYPE_PINNED 表示当前 Device Allocation 类型,不是 cudaHostAlloc 创建的 Host Pinned Memory。
#include <cuda.h>
#include <cstddef>
struct VmmAllocation {
CUmemGenericAllocationHandle handle;
CUdeviceptr address;
size_t allocation_size;
};
size_t round_up(size_t value, size_t alignment) {
return (value + alignment - 1) / alignment * alignment;
}
void set_rw_access(CUdeviceptr address, size_t size, int device_id) {
CUmemAccessDesc access{};
access.location.type = CU_MEM_LOCATION_TYPE_DEVICE;
access.location.id = device_id;
access.flags = CU_MEM_ACCESS_FLAGS_PROT_READWRITE;
cuMemSetAccess(address, size, &access, 1);
}
VmmAllocation create_exportable_allocation(
int device_id,
size_t requested_size) {
CUmemAllocationProp prop{};
prop.type = CU_MEM_ALLOCATION_TYPE_PINNED;
prop.location.type = CU_MEM_LOCATION_TYPE_DEVICE;
prop.location.id = device_id;
prop.requestedHandleTypes =
CU_MEM_HANDLE_TYPE_POSIX_FILE_DESCRIPTOR;
size_t granularity = 0;
cuMemGetAllocationGranularity(
&granularity,
&prop,
CU_MEM_ALLOC_GRANULARITY_MINIMUM);
VmmAllocation allocation{};
allocation.allocation_size =
round_up(requested_size, granularity);
// 此时只获得物理 Allocation Handle,尚不能读写。
cuMemCreate(
&allocation.handle,
allocation.allocation_size,
&prop,
0);
cuMemAddressReserve(
&allocation.address,
allocation.allocation_size,
granularity,
0,
0);
cuMemMap(
allocation.address,
allocation.allocation_size,
0,
allocation.handle,
0);
// cuMemMap 不会自动授予访问权限。
set_rw_access(
allocation.address,
allocation.allocation_size,
device_id);
return allocation;
}创建 Allocation 前应检查 VMM 能力:
int supported = 0;
cuDeviceGetAttribute(
&supported,
CU_DEVICE_ATTRIBUTE_VIRTUAL_ADDRESS_MANAGEMENT_SUPPORTED,
device);4.2 导出 POSIX fd
Exporter 将 VMM Allocation 导出为 POSIX File Descriptor:
VmmAllocation allocation =
create_exportable_allocation(0, requested_size);
int exported_fd = -1;
cuMemExportToShareableHandle(
&exported_fd,
allocation.handle,
CU_MEM_HANDLE_TYPE_POSIX_FILE_DESCRIPTOR,
0);File Descriptor 的整数值只在所属进程的 FD Table 中有意义。例如 Process 0 的 fd=17 与 Process 1 自己的 fd=17 没有必然关系,不能使用普通 write(socket, &fd, sizeof(fd)) 传递。Linux 上应通过 Unix Domain Socket 的 SCM_RIGHTS 发送:
#include <sys/socket.h>
void send_fd(int socket_fd, int fd) {
char payload = 0;
struct iovec iov{&payload, sizeof(payload)};
char control[CMSG_SPACE(sizeof(int))]{};
struct msghdr message{};
message.msg_iov = &iov;
message.msg_iovlen = 1;
message.msg_control = control;
message.msg_controllen = sizeof(control);
struct cmsghdr* cmsg = CMSG_FIRSTHDR(&message);
cmsg->cmsg_level = SOL_SOCKET;
cmsg->cmsg_type = SCM_RIGHTS;
cmsg->cmsg_len = CMSG_LEN(sizeof(int));
std::memcpy(CMSG_DATA(cmsg), &fd, sizeof(fd));
sendmsg(socket_fd, &message, 0);
}
int recv_fd(int socket_fd) {
char payload = 0;
struct iovec iov{&payload, sizeof(payload)};
char control[CMSG_SPACE(sizeof(int))]{};
struct msghdr message{};
message.msg_iov = &iov;
message.msg_iovlen = 1;
message.msg_control = control;
message.msg_controllen = sizeof(control);
recvmsg(socket_fd, &message, 0);
struct cmsghdr* cmsg = CMSG_FIRSTHDR(&message);
if (cmsg == nullptr ||
cmsg->cmsg_level != SOL_SOCKET ||
cmsg->cmsg_type != SCM_RIGHTS) {
throw std::runtime_error("failed to receive VMM fd");
}
int fd = -1;
std::memcpy(&fd, CMSG_DATA(cmsg), sizeof(fd));
return fd;
}SCM_RIGHTS 会在接收进程的 FD Table 中安装一个引用同一内核对象的新 FD。sendmsg 完成后 Exporter 可以关闭自己的 exported_fd,但不能提前释放 CUDA Allocation。
4.3 Import、Map 与 SetAccess
Importer 收到 POSIX fd 后,将其恢复为 CUmemGenericAllocationHandle,再映射到自己的 CUDA 虚拟地址空间。Allocation Size 不是 fd 的替代信息,应通过普通控制消息单独传递。
CUdeviceptr import_vmm_allocation(
int received_fd,
size_t allocation_size,
int consumer_device,
CUmemGenericAllocationHandle* imported_handle) {
cuMemImportFromShareableHandle(
imported_handle,
reinterpret_cast<void*>(
static_cast<intptr_t>(received_fd)),
CU_MEM_HANDLE_TYPE_POSIX_FILE_DESCRIPTOR);
// Import 成功后不再需要这个 OS Handle。
close(received_fd);
CUmemAllocationProp prop{};
cuMemGetAllocationPropertiesFromHandle(
&prop,
*imported_handle);
size_t granularity = 0;
cuMemGetAllocationGranularity(
&granularity,
&prop,
CU_MEM_ALLOC_GRANULARITY_MINIMUM);
CUdeviceptr peer_address = 0;
cuMemAddressReserve(
&peer_address,
allocation_size,
granularity,
0,
0);
cuMemMap(
peer_address,
allocation_size,
0,
*imported_handle,
0);
set_rw_access(
peer_address,
allocation_size,
consumer_device);
return peer_address;
}随后可以把 peer_address 当作普通 Device Pointer 传给 Kernel:
CUmemGenericAllocationHandle imported_handle;
CUdeviceptr peer_address = import_vmm_allocation(
received_fd,
allocation_size,
1,
&imported_handle);
size_t count = logical_size / sizeof(float);
add_one<<<(count + 255) / 256, 256>>>(
reinterpret_cast<float*>(peer_address), count);
cudaDeviceSynchronize();4.4 清理顺序
Importer 应按以下顺序清理:
cuMemUnmap(peer_address, allocation_size);
cuMemAddressFree(peer_address, allocation_size);
cuMemRelease(imported_handle);Exporter 在所有 Importer 完成后清理自己的映射和 Allocation:
cuMemUnmap(allocation.address, allocation.allocation_size);
cuMemAddressFree(allocation.address, allocation.allocation_size);
cuMemRelease(allocation.handle);cuMemRelease 释放的是 Allocation Handle 引用。底层物理显存只有在相关 Mapping、Generic Allocation Handle 和导出的 Shareable Handle 都被释放后才会归还给系统。
5. Legacy CUDA IPC 与 VMM-based IPC 的关系
NVIDIA 文档将 cudaIpcGetMemHandle 这一组接口归入 Legacy IPC。VMM-based IPC 提供另一种更显式的 Memory Sharing 模型,两者是并列的公开接口,不是通信算法,也不是上下级调用关系。
两者最直观的工程区别是 API 层级和控制粒度:Legacy CUDA IPC 通常使用较高层的 CUDA Runtime API,例如 cudaIpcGetMemHandle 与 cudaIpcOpenMemHandle,由 CUDA 隐式完成远端 Allocation 的导入和虚拟地址映射;VMM-based IPC 使用更底层的 CUDA Driver API,例如 cuMemCreate、cuMemAddressReserve、cuMemMap 与 cuMemSetAccess,由应用显式管理物理 Allocation、虚拟地址和访问权限。
Legacy CUDA IPC / Runtime API
cudaMalloc
↓
cudaIpcGetMemHandle / cudaIpcOpenMemHandle
↓
Driver 隐式建立 Peer Mapping
VMM-based IPC / Driver API
cuMemCreate Physical Allocation
+
cuMemAddressReserve Virtual Address
+
cuMemMap VA → Allocation Mapping
+
cuMemSetAccess Per-device Access PermissionDriver API 也提供 cuIpcGetMemHandle、cuIpcOpenMemHandle 等 Legacy CUDA IPC 接口。Legacy CUDA IPC 并非只能通过 Runtime API 使用;它与 VMM-based IPC 的核心区别是前者提供较完整的自动映射抽象,后者通过 Driver API 暴露更细粒度的地址空间控制。
跨进程共享 GPU Device Memory
├─ Legacy CUDA IPC
│ ├─ cudaMalloc
│ ├─ cudaIpcGetMemHandle
│ └─ cudaIpcOpenMemHandle
│
└─ VMM-based IPC
├─ cuMemCreate
├─ cuMemExportToShareableHandle
├─ cuMemImportFromShareableHandle
├─ cuMemAddressReserve
├─ cuMemMap
└─ cuMemSetAccess| 对比项 | Legacy CUDA IPC | VMM-based IPC |
|---|---|---|
| 常用 API 层级 | CUDA Runtime API 的 cudaIpc*;也有 Driver API 的 cuIpc* | CUDA Driver API 的 cuMem* |
| 映射控制 | OpenMemHandle 时由 CUDA 隐式完成 | 应用显式执行 Reserve、Map、SetAccess |
| 典型来源 | cudaMalloc Allocation | cuMemCreate Allocation |
| 导出对象 | 完整 CUDA Allocation | Generic Physical Allocation Handle |
| 导出形式 | cudaIpcMemHandle_t | POSIX fd、Win32 Handle 或 Fabric Handle |
| VA 控制 | Driver 自动选择并返回 Importer Pointer | 应用显式 Reserve、Map、Unmap |
| 访问权限 | Open 时按需建立 Peer Access | cuMemSetAccess 显式配置 |
| 多 Allocation 拼接 | 不直接提供 | 可以把多个 Allocation 映射到连续 VA |
| 使用复杂度 | 较低 | 较高 |
| 典型用途 | 固定通信 Buffer、简单单机共享 | 可扩展 Allocator、显式地址布局、跨 API 互操作 |
同一个 Physical Allocation 不需要先经过 Legacy CUDA IPC 再经过 VMM-based IPC。应用根据 Allocation 的创建方式选择匹配的导出路径:
cudaMalloc-backed Pointer
→ cudaIpcGetMemHandle / cudaIpcOpenMemHandle
cuMemCreate + cuMemMap-backed Pointer
→ cuMemExportToShareableHandle
→ cuMemImportFromShareableHandle
→ cuMemMapcuMemRetainAllocationHandle 可以从已经由 cuMemMap 映射的地址恢复其 Generic Allocation Handle,但它不能把普通 cudaMalloc Pointer 转换成 VMM Allocation。
6. 性能关系
两种机制完成映射后,Kernel 都通过 GPU 虚拟地址访问同一类 Device Memory,远端请求也都经过实际的 NVLink、NVSwitch 或 PCIe 路径。因此,在物理 Allocation、访问权限、地址对齐和 Kernel 完全相同时,稳态 Load/Store 性能通常接近。
主要差异出现在映射建立与完整数据路径:
| 阶段 | Legacy CUDA IPC | VMM-based IPC |
|---|---|---|
| 注册 | Handle 获取与 Open 相对简单 | Export、OS Handle 传递、Import、Reserve、Map、SetAccess |
| 稳态访问 | 普通 Peer Pointer Load/Store | 普通 Peer Pointer Load/Store |
| 地址布局 | Driver 决定 | 应用可控制映射与拼接 |
| 碎片与 TLB | 取决于原始 Allocation | 还受 Chunk 数量和 Mapping Granularity 影响 |
公平 Benchmark 应把以下指标分开:
- Allocation 与 Handle 注册时间。
- 第一次访问或第一次 Kernel 的冷启动时间。
- Warmup 后 Kernel-only 延迟和有效带宽。
- 是否存在额外 Device-to-Device Staging Copy。
- P2P 实际经过 NVLink、NVSwitch 还是 PCIe。
VMM 注册更复杂并不意味着每次 Peer Load 都更慢;同样,某条 VMM 路径更快也可能只是因为它减少了额外 Copy,而不是 cuMemMap 本身提高了链路带宽。
7. 正确性与安全边界
实现共享显存通信时需要同时满足以下约束:
- 生命周期:Exporter 必须晚于全部 Importer 释放原 Allocation。
- 生产者—消费者同步:指针可访问不表示数据已经写完,应使用 Event、Stream Dependency、Barrier 或控制消息。
- Buffer 复用:循环通信需要 Slot 或 Epoch,不能在消费者完成前覆盖数据。
- 内存顺序:自定义 GPU Flag 协议需要正确的 Release/Acquire 或 System-scope 原子语义,不能只依赖普通 Load/Store 和
volatile推断跨设备可见性。 - 错误处理:生产代码必须检查所有 CUDA 返回值,并处理部分注册成功后的回滚。
- Handle 权限:IPC/VMM Handle 是访问显存的能力凭证,不应发送给不可信进程。
- 进程模型:避免在已经初始化 CUDA 的多线程进程中直接
fork();优先使用独立启动的 Worker 或spawn模式。