学习目标
学完本节你将能够:
- 理解多 GPU 环境下数据在设备间传输的基本方式
- 掌握
cudaMemcpyPeer和cudaMemcpyPeerAsync的使用方法 - 了解点对点(P2P)传输的硬件支持和性能特点
- 使用
cudaDeviceEnablePeerAccess启用设备间直接访问 - 分析 P2P 传输与主机中转传输的性能差异
- 在多 GPU 程序中合理选择数据传输路径
1. 多 GPU 数据传输的两种方式
当数据需要在两个 GPU 之间移动时,有两种基本方式:
1.1 主机中转(Staging through Host)
GPU0 → 主机内存 → GPU1
使用两次 cudaMemcpy 完成:
cudaMemcpy(h_buf, d_data0, size, cudaMemcpyDeviceToHost); // GPU0 → 主机
cudaMemcpy(d_data1, h_buf, size, cudaMemcpyHostToDevice); // 主机 → GPU1
特点:
- 需要分配主机缓冲区
- 经过 PCIe 两次,带宽减半
- 所有支持 CUDA 的 GPU 都可以使用
1.2 点对点直接传输(P2P)
GPU0 → GPU1(通过 PCIe 或 NVLink)
使用 cudaMemcpyPeer 或 cudaMemcpyPeerAsync:
cudaMemcpyPeer(dst, dstDevice, src, srcDevice, size);
特点:
- 不需要主机缓冲区
- 如果硬件支持 P2P,可以直接通过 PCIe 或 NVLink 传输,性能更高
- 需要检查硬件是否支持,并启用 P2P 访问
2. 检查 P2P 支持与启用
2.1 检查设备属性
int deviceCount;
cudaGetDeviceCount(&deviceCount);
for (int i = 0; i < deviceCount; i++) {
cudaDeviceProp prop;
cudaGetDeviceProperties(&prop, i);
printf("Device %d: %s, PCIe Gen %d, NVLink %d\n",
i, prop.name, prop.pciBusID, prop.nvlink);
}
关键属性:
prop.unifiedAddressing:是否支持统一寻址prop.managedMemory:是否支持统一内存prop.concurrentManagedAccess:是否支持并发统一内存访问prop.asyncEngineCount:异步引擎数量prop.nvlink:是否支持 NVLink(较新 CUDA 版本)
2.2 检查 P2P 能力
int canAccessPeer = 0;
cudaDeviceCanAccessPeer(&canAccessPeer, device0, device1);
if (canAccessPeer) {
cudaSetDevice(device0);
cudaDeviceEnablePeerAccess(device1, 0); // 允许 device0 访问 device1
}
cudaDeviceCanAccessPeer 返回 1 表示两个设备之间可以启用 P2P。但即使支持,某些情况下也可能通过主机中转(例如跨 PCIe 根复合体)。
3. P2P 传输 API 的使用
3.1 同步传输 cudaMemcpyPeer
cudaError_t cudaMemcpyPeer(void* dst, int dstDevice,
const void* src, int srcDevice,
size_t count);
dst:目标设备指针dstDevice:目标设备 IDsrc:源设备指针srcDevice:源设备 IDcount:字节数
调用前必须确保两个设备之间的 P2P 访问已启用(或当前设备上下文正确)。
3.2 异步传输 cudaMemcpyPeerAsync
cudaError_t cudaMemcpyPeerAsync(void* dst, int dstDevice,
const void* src, int srcDevice,
size_t count, cudaStream_t stream);
- 在指定流中异步执行,便于与计算重叠。
3.3 完整示例:两 GPU 间数据传输
#include <cstdio>
#include <cuda_runtime.h>
#define CUDA_CHECK(call) \
do { \
cudaError_t err = call; \
if (err != cudaSuccess) { \
fprintf(stderr, "CUDA error at %s:%d: %s\n", __FILE__, __LINE__, cudaGetErrorString(err)); \
exit(EXIT_FAILURE); \
} \
} while (0)
int main() {
int deviceCount;
CUDA_CHECK(cudaGetDeviceCount(&deviceCount));
if (deviceCount < 2) {
printf("Need at least 2 GPUs\n");
return -1;
}
int dev0 = 0, dev1 = 1;
// 检查 P2P 支持
int canAccessPeer = 0;
CUDA_CHECK(cudaDeviceCanAccessPeer(&canAccessPeer, dev0, dev1));
printf("P2P between GPU0 and GPU1: %s\n", canAccessPeer ? "Yes" : "No");
if (!canAccessPeer) {
printf("P2P not supported, exiting.\n");
return -1;
}
// 启用 P2P 访问
CUDA_CHECK(cudaSetDevice(dev0));
CUDA_CHECK(cudaDeviceEnablePeerAccess(dev1, 0));
CUDA_CHECK(cudaSetDevice(dev1));
CUDA_CHECK(cudaDeviceEnablePeerAccess(dev0, 0));
const int N = 1 << 20;
size_t bytes = N * sizeof(float);
float *d_data0, *d_data1;
CUDA_CHECK(cudaSetDevice(dev0));
CUDA_CHECK(cudaMalloc(&d_data0, bytes));
CUDA_CHECK(cudaSetDevice(dev1));
CUDA_CHECK(cudaMalloc(&d_data1, bytes));
// 初始化 d_data0(省略)
// ...
// P2P 传输:GPU0 → GPU1
CUDA_CHECK(cudaMemcpyPeer(d_data1, dev1, d_data0, dev0, bytes));
printf("P2P copy completed.\n");
// 清理
CUDA_CHECK(cudaSetDevice(dev0));
CUDA_CHECK(cudaFree(d_data0));
CUDA_CHECK(cudaSetDevice(dev1));
CUDA_CHECK(cudaFree(d_data1));
return 0;
}
4. P2P 与主机中转的性能对比
4.1 性能差异来源
- P2P 传输:数据直接从 GPU0 到 GPU1,只经过一次总线。如果两个 GPU 通过 NVLink 连接,带宽远高于 PCIe。
- 主机中转:需要两次 PCIe 传输(GPU0→主机,主机→GPU1),带宽减半,且主机内存带宽可能成为瓶颈。
4.2 实验设计
编写程序分别测量:
- P2P 传输带宽(使用
cudaMemcpyPeer) - 主机中转带宽(使用两次
cudaMemcpy或cudaMemcpyAsync)
使用 cudaEvent 计时,并计算有效带宽。
5. NVLink 与多 GPU 通信
NVLink 是 NVIDIA 的高速互联技术,提供比 PCIe 高数倍的带宽和更低的延迟。在支持 NVLink 的系统上,P2P 传输性能极大提升。
5.1 查询 NVLink 拓扑
// 使用 CUDA 的 NVLink 属性(较新 CUDA 版本)
cudaDeviceProp prop;
cudaGetDeviceProperties(&prop, deviceId);
// prop.nvlink 可能表示支持的 NVLink 数量
更详细的拓扑信息可以通过 nvidia-smi topo -m 命令查看。
5.2 对 P2P 的影响
- 如果两个 GPU 通过 NVLink 连接,
cudaDeviceCanAccessPeer返回 true,且带宽较高。 - 如果没有 NVLink,P2P 也可能通过 PCIe 工作,但带宽受 PCIe 限制。
6. 代码演示:P2P 与主机中转带宽对比
#include <cstdio>
#include <cuda_runtime.h>
#include <chrono>
#define CUDA_CHECK(call) \
do { \
cudaError_t err = call; \
if (err != cudaSuccess) { \
fprintf(stderr, "CUDA error at %s:%d: %s\n", __FILE__, __LINE__, cudaGetErrorString(err)); \
exit(EXIT_FAILURE); \
} \
} while (0)
int main() {
int deviceCount;
CUDA_CHECK(cudaGetDeviceCount(&deviceCount));
if (deviceCount < 2) {
printf("Need at least 2 GPUs\n");
return -1;
}
int dev0 = 0, dev1 = 1;
int canPeer = 0;
CUDA_CHECK(cudaDeviceCanAccessPeer(&canPeer, dev0, dev1));
if (!canPeer) {
printf("P2P not supported\n");
return -1;
}
CUDA_CHECK(cudaSetDevice(dev0));
CUDA_CHECK(cudaDeviceEnablePeerAccess(dev1, 0));
CUDA_CHECK(cudaSetDevice(dev1));
CUDA_CHECK(cudaDeviceEnablePeerAccess(dev0, 0));
const int N = 1 << 26; // 64M floats = 256 MB
size_t bytes = N * sizeof(float);
float *d_data0, *d_data1, *h_buf;
CUDA_CHECK(cudaSetDevice(dev0));
CUDA_CHECK(cudaMalloc(&d_data0, bytes));
CUDA_CHECK(cudaSetDevice(dev1));
CUDA_CHECK(cudaMalloc(&d_data1, bytes));
CUDA_CHECK(cudaMallocHost(&h_buf, bytes)); // 页锁定主机内存
// 初始化 d_data0(省略)
// 计时 P2P 传输
auto start = std::chrono::high_resolution_clock::now();
for (int i = 0; i < 10; i++) {
CUDA_CHECK(cudaMemcpyPeer(d_data1, dev1, d_data0, dev0, bytes));
}
CUDA_CHECK(cudaDeviceSynchronize());
auto end = std::chrono::high_resolution_clock::now();
double p2p_ms = std::chrono::duration<double, std::milli>(end - start).count() / 10.0;
double p2p_bandwidth = (bytes / 1e9) / (p2p_ms / 1e3); // GB/s
// 计时主机中转传输
start = std::chrono::high_resolution_clock::now();
for (int i = 0; i < 10; i++) {
CUDA_CHECK(cudaMemcpy(h_buf, d_data0, bytes, cudaMemcpyDeviceToHost));
CUDA_CHECK(cudaMemcpy(d_data1, h_buf, bytes, cudaMemcpyHostToDevice));
}
CUDA_CHECK(cudaDeviceSynchronize());
end = std::chrono::high_resolution_clock::now();
double staging_ms = std::chrono::duration<double, std::milli>(end - start).count() / 10.0;
double staging_bandwidth = (bytes / 1e9) / (staging_ms / 1e3);
printf("P2P bandwidth: %f GB/s\n", p2p_bandwidth);
printf("Staging bandwidth: %f GB/s\n", staging_bandwidth);
printf("Speedup: %.2fx\n", p2p_bandwidth / staging_bandwidth);
// 清理
CUDA_CHECK(cudaSetDevice(dev0));
CUDA_CHECK(cudaFree(d_data0));
CUDA_CHECK(cudaSetDevice(dev1));
CUDA_CHECK(cudaFree(d_data1));
CUDA_CHECK(cudaFreeHost(h_buf));
return 0;
}
预期结果:P2P 带宽通常高于主机中转(尤其有 NVLink 时),可能快 1.5~3 倍。
7. 课后练习
练习1:检查 P2P 支持
编写程序,枚举系统中所有 GPU 对,检查它们之间的 P2P 支持情况,并输出拓扑矩阵。
练习2:P2P 异步传输
使用 cudaMemcpyPeerAsync 将数据在两个 GPU 间传输,同时执行一个 Kernel,观察是否能够重叠。
练习3:性能对比
在你的多 GPU 环境上运行本节的带宽对比程序,记录 P2P 和主机中转的带宽,并分析你的硬件拓扑(使用 nvidia-smi topo -m)。
练习4:统一内存 vs P2P
比较使用统一内存(cudaMallocManaged)与 P2P 传输在多 GPU 间共享数据的性能差异。思考各自的适用场景。
练习5:错误处理
故意在不启用 P2P 的情况下调用 cudaMemcpyPeer,观察错误码,并编写正确的检查流程。
8. 下一步
下一节将进入 内存池与异步拷贝优化,你将学习:
- 使用内存池减少分配/释放开销
- 异步拷贝与流的高级应用
- CUDA 的内存压缩与缓存管理
- 多 GPU 环境下的内存管理策略