4、CUDA内存管理:全局内存、共享内存、常量内存、寄存器、内存访问优化

说到CUDA编程,内存管理绝对是绕不开的核心话题。我刚开始接触GPU计算时,总觉得把数据扔到显存里就能自动跑得快。结果呢?第一次跑金融风险模型,性能还不如CPU。后来我才明白——内存访问模式,才是决定性能的关键

GPU的内存层次结构,说白了就是一个「速度与容量」的博弈。离计算单元越近,速度越快,但容量越小。咱们得学会在这个层次结构中,把数据放到最合适的位置。

核心观点:CUDA性能优化的本质,就是让数据尽可能待在「快」的内存里,同时保证访问模式是连续的。

CUDA内存层次结构 寄存器 (Register) ~20 TB/s, 每个线程私有 共享内存 (Shared Memory) ~1.5 TB/s, 线程块内共享 L1缓存 / 纹理内存 常量内存 (Constant Memory) — 只读,广播机制 全局内存 (Global Memory) — 最大,最慢 ~200 GB/s, 所有线程可访问

4.1 全局内存:GPU的「大仓库」

全局内存是GPU里容量最大的存储空间,所有线程都能读写。但它的延迟也最高——大概400-800个时钟周期。你想想看,CPU访问L1缓存只要几个周期,这差距有多大。

我在做期权定价模型时,一开始把整个波动率曲面都放在全局内存里。结果每个线程都要随机访问不同位置的波动率数据,性能惨不忍睹。后来我做了两件事:合并访问 + 数据预取,性能直接翻了3倍。

合并访问原则:同一个warp(32个线程)的线程,应该访问连续的内存地址。这样GPU可以把多次内存请求合并成一次大的传输。

// ❌ 糟糕的访问模式:按列访问
for (int i = 0; i < N; i++) {
    float val = data[threadIdx.x + i * blockDim.x];  // 跨步访问
}

// ✅ 好的访问模式:按行访问
for (int i = 0; i < N; i++) {
    float val = data[threadIdx.x + i];  // 连续访问
}

4.2 共享内存:线程块的「小团队缓存」

共享内存是片上的SRAM,延迟只有几十个周期。它在一个线程块内所有线程共享。说白了,这就是你手动控制的L1缓存。

我记得有一次做矩阵转置,全局内存版本带宽利用率只有15%。加上共享内存做分块转置后,利用率直接飙到80%以上。为什么?因为共享内存允许我们把数据先搬到片上,再按转置后的顺序写回全局内存。

关键用法:共享内存常用于数据重用、规约操作、以及避免非合并访问。

// 共享内存声明
__shared__ float shared_data[256];

// 典型用法:分块矩阵乘法
__global__ void matmul_shared(float *A, float *B, float *C, int N) {
    __shared__ float As[TILE_SIZE][TILE_SIZE];
    __shared__ float Bs[TILE_SIZE][TILE_SIZE];
    
    int row = blockIdx.y * TILE_SIZE + threadIdx.y;
    int col = blockIdx.x * TILE_SIZE + threadIdx.x;
    float sum = 0.0f;
    
    for (int t = 0; t < N / TILE_SIZE; t++) {
        As[threadIdx.y][threadIdx.x] = A[row * N + t * TILE_SIZE + threadIdx.x];
        Bs[threadIdx.y][threadIdx.x] = B[(t * TILE_SIZE + threadIdx.y) * N + col];
        __syncthreads();
        
        for (int k = 0; k < TILE_SIZE; k++) {
            sum += As[threadIdx.y][k] * Bs[k][threadIdx.x];
        }
        __syncthreads();
    }
    C[row * N + col] = sum;
}

注意:共享内存使用不当会导致bank conflict。当多个线程访问同一个bank的不同地址时,访问会被串行化。我建议尽量让线程访问连续的共享内存地址,或者使用padding技巧来避免冲突。

4.3 常量内存:只读数据的「广播站」

常量内存只有64KB,但它的厉害之处在于——当一个warp里的所有线程访问同一个常量地址时,硬件会把这个值广播给所有线程,只花一个周期!

我在做蒙特卡洛模拟时,把随机数生成器的参数放在常量内存里。因为所有线程都用同一组参数,广播机制让访问几乎零开销。但要注意,如果warp里的线程访问不同的常量地址,那就退化成串行访问了。

// 常量内存声明
__constant__ float params[16];

// 主机端赋值
cudaMemcpyToSymbol(params, host_params, sizeof(float) * 16);

4.4 寄存器:最快的存储,但有限

寄存器是每个线程私有的,访问延迟为0。但每个线程能用的寄存器数量有限——通常是255个(取决于计算能力)。

我曾经犯过一个错误:在一个核函数里声明了太多局部变量,导致寄存器溢出(register spilling)。变量被挤到局部内存里,而局部内存实际上在全局内存上!性能直接掉了60%。

检查方法:编译时加 --ptxas-options=-v 参数,可以看到每个线程用了多少寄存器。如果超过32个,就要小心了。

4.5 内存访问优化实战策略

好了,理论说完了。咱们来点实际的。我在金融量化团队里总结了一套「内存优化三步法」:

  1. 分析访问模式:用NVIDIA Nsight Compute工具看L1/L2缓存命中率、全局内存负载效率。
  2. 选择合适的内存类型:只读数据放常量内存或纹理内存;频繁重用的数据放共享内存。
  3. 调整数据布局:结构体数组(SoA) vs 数组结构体(AoS)——GPU更适合SoA。
内存类型 延迟 容量 作用域 典型用途
寄存器 0周期 ~256个/线程 线程私有 临时变量、循环计数器
共享内存 ~30周期 48KB/块 线程块内 数据重用、规约
常量内存 ~1周期(广播) 64KB 全局只读 模型参数、查找表
全局内存 ~400周期 GB级别 全局读写 主要数据存储

避坑指南:我曾经在回测系统里用全局内存存储历史K线数据,每个线程随机访问不同时间点的数据。结果带宽利用率不到10%。后来我把数据按时间排序,让相邻线程访问相邻时间点,利用率提升到70%。记住:访问模式比内存类型本身更重要

嗯,最后说一句。内存优化没有银弹。你得根据具体算法、数据规模、GPU型号来调整。我建议你养成一个习惯:每次写完核函数,先用profiler跑一遍,看看内存瓶颈在哪。别像我刚开始那样,闷头写代码,结果优化了半天才发现方向错了。


专注资料整理