3、CUDA统一内存编程:cudaMallocManaged用法、页面迁移机制、内存预取与驻留控制

3.1 为什么需要统一内存?

做GPU编程的人,十有八九都被内存管理折磨过。

传统CUDA编程里,你得手动管两套内存:主机端用malloc,设备端用cudaMalloc。数据要搬来搬去,还得记着cudaMemcpy。代码写起来像这样:

float *h_data, *d_data;
h_data = (float*)malloc(N * sizeof(float));
cudaMalloc(&d_data, N * sizeof(float));
// 初始化h_data...
cudaMemcpy(d_data, h_data, N * sizeof(float), cudaMemcpyHostToDevice);
// 跑kernel...
cudaMemcpy(h_data, d_data, N * sizeof(float), cudaMemcpyDeviceToHost);
free(h_data);
cudaMalloc(d_data);

烦不烦?我当年刚入行时,就因为这破事debug了整整三天——最后发现是cudaMemcpy的方向写反了。

统一内存(Unified Memory)就是来解决这个痛点的。它让你只用一套指针,CPU和GPU都能访问。系统自动帮你搬数据。

3.2 cudaMallocManaged:一键分配,双端访问

核心API就一个:cudaMallocManaged

float *data;
cudaMallocManaged(&data, N * sizeof(float));

// CPU端直接用
for (int i = 0; i < N; i++) data[i] = i * 1.0f;

// GPU端也直接用
my_kernel<<<grid, block>>>(data, N);

cudaDeviceSynchronize();
// CPU再读结果
printf("data[0] = %f\n", data[0]);

cudaFree(data);

你看,没有malloc,没有cudaMemcpy,没有两套指针。清爽多了。

我的习惯: 项目初期原型验证时,我几乎只用cudaMallocManaged。开发速度快了不止一倍。等性能调优阶段,再考虑换成手动管理。

但注意,cudaMallocManaged返回的指针,默认是“驻留在CPU端”的。GPU第一次访问时,才会触发迁移。

3.3 页面迁移机制:数据是怎么“跑”过去的?

统一内存背后,是CUDA驱动在管一个“页面迁移引擎”。

说白了,它把物理内存分成4KB大小的页面。每个页面有个“主人”标签——当前在CPU上,还是在GPU上。

当GPU要访问一个页面,发现它不在显存里,就会触发一个缺页中断。驱动把页面从CPU内存搬到显存,然后更新页表。

这个过程是透明的。但代价呢?

操作 延迟(典型值) 说明
CPU访问本地内存 ~100ns 直接读DRAM
GPU访问本地显存 ~200ns HBM或GDDR
页面迁移(CPU→GPU) ~10μs/页 通过PCIe,4KB一页
缺页中断处理 ~5μs 驱动开销

看到没?一次缺页中断,比一次普通内存访问慢了100倍。如果代码写得不好,频繁触发缺页,性能会崩得很难看。

我曾经踩过的坑: 有一次,我在kernel里用了一个循环,每次迭代都访问一个不同页面的数据。结果每个线程都在触发缺页,整个kernel跑得比CPU还慢。后来用cuda-memcheck一查,发现页面错误次数高达几百万次。

3.4 内存预取:提前把数据准备好

既然缺页这么贵,那能不能提前把数据搬过去?

当然可以。CUDA提供了cudaMemPrefetchAsync

float *data;
cudaMallocManaged(&data, N * sizeof(float));

// 初始化数据(在CPU上)
for (int i = 0; i < N; i++) data[i] = i * 1.0f;

// 预取到GPU,指定设备ID为0
cudaMemPrefetchAsync(data, N * sizeof(float), 0, NULL);

// 现在GPU访问data,不会触发缺页
my_kernel<<<grid, block>>>(data, N);

cudaDeviceSynchronize();

// 结果要读回CPU?再预取回来
cudaMemPrefetchAsync(data, N * sizeof(float), cudaCpuDeviceId, NULL);
printf("data[0] = %f\n", data[0]);

预取的本质,就是告诉驱动:“我马上要用这些数据了,你先搬过去。”

它是个异步操作。调用后立即返回,后台在PCIe上慢慢搬。你可以在搬数据的同时做其他计算,实现计算与数据传输重叠

性能关键: 预取粒度越大越好。一次预取几MB甚至几十MB,比一次预取几KB效率高得多。因为PCIe的带宽利用率,跟传输块大小强相关。

3.5 内存驻留控制:把数据“钉”在显存里

预取只是“建议”。驱动可能会因为显存不够,把一些页面踢回CPU。

如果你确定某个数据要长期留在GPU上,可以用cudaMemAdvise设置驻留提示

cudaMemAdvise(data, N * sizeof(float), cudaMemAdviseSetPreferredLocation, 0);
cudaMemAdvise(data, N * sizeof(float), cudaMemAdviseSetAccessedBy, 0);

第一个参数:告诉驱动,这个数据“最好”放在设备0上。

第二个参数:告诉驱动,设备0会频繁访问这个数据。

这两个提示组合使用,驱动会尽量把数据留在显存里,不会轻易换出。

还有一个更狠的:cudaMemAdviseSetReadMostly。如果数据是只读的(比如查找表),加上这个提示,驱动会在多个设备间复制一份,避免反复迁移。

// 只读数据,每个设备都保留一份副本
cudaMemAdvise(lookup_table, size, cudaMemAdviseSetReadMostly, 0);
我的建议: 对于只读数据,一定要用SetReadMostly。我在一个图像处理项目里,把颜色查找表设成只读后,多GPU场景下的性能提升了40%。因为每个GPU都有自己的副本,不用抢着访问同一块内存。

3.6 实战:一个完整的预取+驻留示例

把上面这些串起来,写一个完整的例子:

#include <cuda_runtime.h>
#include <stdio.h>

__global__ void vector_add(float *a, float *b, float *c, int n) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < n) c[idx] = a[idx] + b[idx];
}

int main() {
    int n = 1 << 20;  // 约100万个元素
    size_t size = n * sizeof(float);

    float *a, *b, *c;
    cudaMallocManaged(&a, size);
    cudaMallocManaged(&b, size);
    cudaMallocManaged(&c, size);

    // 初始化(在CPU上)
    for (int i = 0; i < n; i++) {
        a[i] = i * 1.0f;
        b[i] = (n - i) * 1.0f;
    }

    // 设置驻留提示:a和b是只读的,c是输出
    cudaMemAdvise(a, size, cudaMemAdviseSetReadMostly, 0);
    cudaMemAdvise(b, size, cudaMemAdviseSetReadMostly, 0);
    cudaMemAdvise(c, size, cudaMemAdviseSetPreferredLocation, 0);

    // 预取到GPU
    cudaMemPrefetchAsync(a, size, 0, NULL);
    cudaMemPrefetchAsync(b, size, 0, NULL);
    cudaMemPrefetchAsync(c, size, 0, NULL);

    // 跑kernel
    int threads = 256;
    int blocks = (n + threads - 1) / threads;
    vector_add<<<blocks, threads>>>(a, b, c, n);

    cudaDeviceSynchronize();

    // 结果预取回CPU
    cudaMemPrefetchAsync(c, size, cudaCpuDeviceId, NULL);
    cudaDeviceSynchronize();

    // 验证
    for (int i = 0; i < 10; i++) {
        printf("c[%d] = %f (expected %f)\n", i, c[i], (float)n);
    }

    cudaFree(a);
    cudaFree(b);
    cudaFree(c);
    return 0;
}

这个例子,把预取和驻留都用上了。a和b是只读的,每个GPU都保留副本。c是输出,固定在设备0上。kernel跑的时候,几乎不会触发缺页。

3.7 避坑指南

统一内存虽好,但有几个坑要小心:

  • 不要在小数据上滥用:如果数据只有几百字节,预取的开销比直接缺页还大。小数据就让驱动自己处理。
  • 注意同步点:cudaMemPrefetchAsync是异步的。如果你紧接着就启动kernel,可能数据还没搬完。建议在预取和kernel之间加一个cudaStreamSynchronize,或者用事件同步。
  • 多GPU场景要小心:每个GPU有自己的显存。如果数据在多个GPU间频繁切换,迁移开销会爆炸。尽量让数据固定在一个设备上。
  • 不要和手动cudaMemcpy混用:统一内存的页面由驱动管理。如果你手动cudaMemcpy一部分数据,可能会破坏页表一致性。要么全自动,要么全手动。
我曾经犯过的错: 在一个多GPU服务器上,我用了统一内存,但没有设置任何驻留提示。结果两个GPU轮流访问同一块数据,每次访问都触发页面迁移。性能比单GPU还差。后来加上cudaMemAdviseSetPreferredLocation,把数据固定在一个GPU上,另一个GPU通过NVLink直接访问,性能才恢复正常。

3.8 小结

统一内存,说白了就是让CPU和GPU共享一套地址空间。cudaMallocManaged帮你分配,驱动帮你搬数据。

但“自动”不等于“免费”。缺页中断很贵,预取和驻留提示是性能的关键。

我个人习惯是:原型阶段无脑用cudaMallocManaged,快速验证。优化阶段再根据profile结果,加上预取和驻留控制。这样既保证了开发效率,又不会牺牲最终性能。

下一章,我们会深入统一内存的底层实现——看看页表是怎么管理的,以及如何用UVMA(Unified Virtual Memory Architecture)做更精细的控制。