学习目标
学完本节你将能够:
- 理解统一内存(Unified Memory)的按需迁移机制与页错误处理
- 使用
cudaMemPrefetchAsync主动预取数据,避免首次访问延迟 - 使用
cudaMemAdvise设置内存使用建议,优化数据放置 - 了解多 GPU 环境下统一内存的行为与注意事项
- 根据应用场景评估统一内存与显式拷贝的优劣,做出合理选择
- 使用 Nsight Systems 或 ncu 分析统一内存的迁移开销
1. 统一内存的按需迁移与页错误
统一内存的核心机制是按需迁移:当主机或设备访问某个统一内存地址时,如果数据不在访问方,硬件会触发页错误,然后自动将数据从另一端迁移过来。
1.1 首次访问的代价
首次访问统一内存的页面会产生页错误,导致数据迁移,带来显著的延迟。如果程序在 Kernel 中频繁触发页错误,性能会大幅下降。
示例:一个 Kernel 遍历整个数组,但数据最初在主机端,第一次被设备访问时会逐页迁移,可能导致 Kernel 执行时间比显式拷贝还长。
1.2 迁移粒度
数据迁移以**页面(通常 4 KB)**为单位,而不是整个分配。因此,如果访问模式具有空间局部性,迁移次数会减少,但仍存在开销。
1.3 与显式拷贝的区别
| 特性 | 统一内存 | 显式拷贝 |
|---|---|---|
| 编程复杂度 | 低,无需管理拷贝 | 高,需手动调度 |
| 首次访问延迟 | 可能高(页错误迁移) | 无(数据已在目标端) |
| 峰值性能 | 可达显式拷贝的 90%+(经过预取优化) | 最高 |
| 适合场景 | 快速原型、复杂数据结构、多GPU | 性能关键、大数据量 |
2. 使用 cudaMemPrefetchAsync 预取数据
2.1 预取的作用
预取可以提前将数据迁移到目标设备,避免 Kernel 执行时的页错误。这在数据访问模式已知的情况下非常有效。
cudaMemPrefetchAsync(ptr, size, destinationDevice, stream);
ptr:统一内存指针size:字节数destinationDevice:目标设备 ID(或cudaCpuDeviceId表示主机)stream:关联的流
2.2 示例:在 Kernel 启动前预取到 GPU
// 分配统一内存
float *data;
cudaMallocManaged(&data, N * sizeof(float));
// 初始化数据(在主机端)
for (int i = 0; i < N; i++) data[i] = 1.0f;
// 预取到 GPU 0
cudaMemPrefetchAsync(data, N * sizeof(float), 0, 0);
cudaDeviceSynchronize();
// 启动 Kernel,不会触发页错误
kernel<<<grid, block>>>(data, N);
cudaDeviceSynchronize();
2.3 预取回主机
如果 Kernel 之后主机需要读取数据,可以预取回主机:
cudaMemPrefetchAsync(data, N * sizeof(float), cudaCpuDeviceId, 0);
cudaDeviceSynchronize();
3. 使用 cudaMemAdvise 优化内存放置
cudaMemAdvise 允许开发者向 CUDA 运行时提供关于内存使用方式的建议,以优化迁移策略。
cudaMemAdvise(ptr, size, advice, device);
3.1 常用建议类型
| 建议 | 含义 | 使用场景 |
|---|---|---|
cudaMemAdviseSetReadMostly |
数据大部分只读 | 权重、查找表 |
cudaMemAdviseSetPreferredLocation |
设置首选位置 | 数据主要在某个设备使用 |
cudaMemAdviseSetAccessedBy |
声明数据会被某设备访问 | 多 GPU 共享数据 |
cudaMemAdviseUnsetReadMostly |
取消只读建议 | 数据变为频繁读写 |
3.2 示例:只读数据优化
// 假设 weight 在 Kernel 中只读
cudaMemAdvise(weight, weightSize, cudaMemAdviseSetReadMostly, 0);
cudaMemAdvise(weight, weightSize, cudaMemAdviseSetPreferredLocation, 0);
这样 CUDA 运行时可以更激进地将数据保留在设备上,减少主机端的拷贝,并在多 GPU 间共享只读副本。
3.3 多 GPU 场景
对于被多个 GPU 访问的数据,可以声明:
cudaMemAdvise(data, size, cudaMemAdviseSetAccessedBy, deviceId1);
cudaMemAdvise(data, size, cudaMemAdviseSetAccessedBy, deviceId2);
运行时会在各设备间复制只读副本,避免频繁迁移。
4. 多 GPU 环境下的统一内存
统一内存支持多 GPU 访问,但需要注意:
- 数据在设备间迁移会通过 PCIe 或 NVLink,开销较大。
- 使用
cudaMemAdviseSetAccessedBy可以让多个 GPU 同时访问同一数据,运行时可能复制副本。 - 预取可以指定目标设备,但数据只能在一个位置,迁移到另一个设备会失效前一个设备上的副本。
最佳实践:
- 尽量让数据在单一设备上使用,或使用只读共享。
- 如果必须多 GPU 频繁访问,考虑显式管理拷贝或使用 NCCL 等通信库。
5. 性能对比与选择准则
5.1 何时使用统一内存
- 快速原型:不想花费时间管理拷贝。
- 复杂数据结构:如链表、树、图,手动拷贝困难。
- 中等数据量:数据量不是特别大,迁移开销可接受。
- 多 GPU 只读共享:权重等只读数据。
5.2 何时使用显式拷贝
- 性能关键:需要榨取每一点带宽,避免页错误。
- 超大数组:统一内存的迁移粒度可能导致额外开销。
- 细粒度控制:需要精确控制数据在主机/设备间移动的时机。
- 多流流水线:显式异步拷贝可以更好地与计算重叠。
5.3 性能对比实验建议
对于同一计算任务,分别用显式拷贝和统一内存(经过预取优化)实现,测量端到端时间。通常统一内存在预取后能达到显式拷贝的 90%95% 性能,但在未优化时可能慢 25 倍。
6. 代码演示:统一内存性能优化前后对比
#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)
__global__ void saxpy(float *y, const float *x, float a, int N) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i < N) y[i] = a * x[i] + y[i];
}
int main() {
const int N = 1 << 24; // 16M floats
size_t bytes = N * sizeof(float);
// 统一内存分配
float *x, *y;
CUDA_CHECK(cudaMallocManaged(&x, bytes));
CUDA_CHECK(cudaMallocManaged(&y, bytes));
// 初始化(在主机端)
for (int i = 0; i < N; i++) { x[i] = 1.0f; y[i] = 2.0f; }
int blockSize = 256;
int gridSize = (N + blockSize - 1) / blockSize;
float a = 2.0f;
// 版本1:不预取,直接启动 Kernel
auto start = std::chrono::high_resolution_clock::now();
saxpy<<<gridSize, blockSize>>>(y, x, a, N);
CUDA_CHECK(cudaDeviceSynchronize());
auto end = std::chrono::high_resolution_clock::now();
double naive_ms = std::chrono::duration<double, std::milli>(end - start).count();
// 重新初始化,避免缓存影响
for (int i = 0; i < N; i++) { x[i] = 1.0f; y[i] = 2.0f; }
// 版本2:预取到 GPU
start = std::chrono::high_resolution_clock::now();
int device = 0;
CUDA_CHECK(cudaMemPrefetchAsync(x, bytes, device, 0));
CUDA_CHECK(cudaMemPrefetchAsync(y, bytes, device, 0));
CUDA_CHECK(cudaDeviceSynchronize());
saxpy<<<gridSize, blockSize>>>(y, x, a, N);
CUDA_CHECK(cudaDeviceSynchronize());
end = std::chrono::high_resolution_clock::now();
double prefetch_ms = std::chrono::duration<double, std::milli>(end - start).count();
printf("Naive unified memory: %f ms\n", naive_ms);
printf("Prefetched unified: %f ms\n", prefetch_ms);
printf("Speedup: %.2fx\n", naive_ms / prefetch_ms);
CUDA_CHECK(cudaFree(x));
CUDA_CHECK(cudaFree(y));
return 0;
}
预期结果:预取版本通常比朴素版本快数倍,因为避免了 Kernel 执行时的页错误迁移。
7. 课后练习
练习1:测量页错误开销
使用 Nsight Systems 或 ncu 观察统一内存 Kernel 的迁移活动,记录页错误次数和迁移耗时。
练习2:比较预取与显式拷贝
实现同一个向量加操作,分别使用显式拷贝、统一内存(无预取)、统一内存(预取),比较端到端时间,并分析差异来源。
练习3:使用 cudaMemAdvise 优化只读数据
对一个只读权重数组使用 cudaMemAdviseSetReadMostly 和 cudaMemAdviseSetPreferredLocation,测试在多 GPU 或单 GPU 下的性能提升。
练习4:多 GPU 统一内存访问
如果有多个 GPU,编写一个程序,在两个 GPU 上分别启动 Kernel 访问同一个统一内存数组(只读),使用 cudaMemAdviseSetAccessedBy 优化,观察性能与数据迁移行为。
练习5:分析迁移粒度
对一个大型结构体数组(如每个元素 100 字节),使用统一内存并观察页错误模式,思考迁移粒度为 4KB 时的影响。
8. 下一步
下一节将进入 设备间拷贝与点对点(P2P)传输,你将学习:
- 如何在多个 GPU 之间直接传输数据,避免经过主机内存
- P2P 传输的硬件支持与性能特点
- 使用
cudaMemcpyPeer和cudaMemcpyPeerAsync - 多 GPU 间共享内存的注意事项
- 通过 NVLink 提升多 GPU 通信带宽