GPU内存层次结构详解

大家好,我是你们的CUDA讲师。今天我们来聊聊GPU的内存体系——说实话,这是整个CUDA编程里最绕不开的核心话题。我见过太多同学,kernel写得挺漂亮,一跑性能就是上不去,最后发现是内存访问模式出了问题。

GPU的内存不像CPU那样简单。它是一套层次化的结构,每一层都有不同的延迟、容量和用途。你想想看,不理解这个层次结构,就像开着一辆超跑却不知道换挡——再强的算力也发挥不出来。

全局内存(Global Memory)

全局内存是GPU里容量最大的存储空间,通常有数GB到数十GB。所有线程都能访问它,但代价是——延迟非常高。

关键特性:

  • 容量:最大(通常4GB~80GB)
  • 延迟:400~800个时钟周期
  • 作用域:所有线程 + host端
  • 缓存:L1/L2缓存(取决于架构)

我个人习惯把全局内存比作「仓库」。东西多,但取一次货要走很远。我在项目中遇到过这样一个案例:一个矩阵乘法kernel,最初直接读写全局内存,性能只有理论峰值的15%。后来改成共享内存分块,直接飙到70%以上。嗯,差距就是这么明显。

访问全局内存时,有个非常重要的原则——合并访问(Coalesced Access)。说白了就是:相邻的线程要访问相邻的地址。为什么?因为GPU会把多个线程的访存请求合并成一次大的内存事务。

// 好的访问模式:合并访问
__global__ void coalesced_kernel(float* data) {
    int idx = threadIdx.x + blockIdx.x * blockDim.x;
    float val = data[idx];  // 相邻线程访问相邻地址
}

// 坏的访问模式:非合并访问
__global__ void strided_kernel(float* data) {
    int idx = threadIdx.x * 32 + blockIdx.x * blockDim.x;
    float val = data[idx];  // 相邻线程访问间隔32的地址
}

避坑指南:我曾经在项目里用了一个二维数组,按列访问数据,结果性能惨不忍睹。后来改成按行访问,速度提升了8倍。记住:行优先存储,行优先访问。

共享内存(Shared Memory)

共享内存是GPU里最「香」的存储资源之一。它位于芯片内部,延迟极低——大约只有全局内存的1/10到1/20。

属性 值
容量 每个SM通常48KB~164KB(可配置)
延迟 约20~40个时钟周期
作用域 同一个线程块内的所有线程
生命周期 线程块执行期间

共享内存的本质是什么?是程序员可控的缓存。你可以手动把数据从全局内存搬到共享内存,然后让线程块内的所有线程快速访问。这特别适合数据重用场景——比如卷积、矩阵乘法。

__global__ void shared_mem_example(float* input, float* output, int N) {
    __shared__ float s_data[256];  // 声明共享内存
    
    int tid = threadIdx.x;
    int gid = blockIdx.x * blockDim.x + tid;
    
    // 从全局内存加载到共享内存
    if (gid < N) {
        s_data[tid] = input[gid];
    }
    __syncthreads();  // 同步!确保所有线程都加载完毕
    
    // 在共享内存上做计算
    // ... 这里可以安全地访问 s_data
    
    // 写回全局内存
    if (gid < N) {
        output[gid] = s_data[tid] * 2.0f;
    }
}

小技巧:使用共享内存时,别忘了__syncthreads()。我刚开始学CUDA时,经常忘记加这个同步,结果数据竞争导致结果时对时错。调试了整整两天才找到原因。

寄存器(Register)

寄存器是GPU里最快的存储,没有之一。每个线程都有自己的私有寄存器,延迟只有1个时钟周期。

但寄存器有个大问题——数量有限。每个SM的寄存器总数是固定的(比如65536个),平均分给所有活跃线程。如果你每个线程用太多寄存器,就会降低SM上同时运行的线程数(occupancy)。

寄存器压力:我见过一个kernel,每个线程用了64个寄存器,结果occupancy只有25%。后来优化到32个寄存器,occupancy提升到50%,性能反而翻倍了。有时候「少即是多」。

怎么查看寄存器使用量?用--ptxas-options=-v编译选项,或者用NVIDIA Nsight工具。我个人习惯在开发阶段经常检查这个指标。

本地内存(Local Memory)

本地内存这个名字容易让人误解。它其实不是「本地」的——它存储在全局内存里,只是每个线程私有的。当寄存器不够用时,编译器会自动把变量「溢出」到本地内存。

注意:本地内存的延迟和全局内存一样高(400+周期)。如果你发现kernel性能突然下降,可以检查一下是不是寄存器溢出导致大量本地内存访问。我曾经在优化一个图像处理kernel时,就因为多声明了几个大数组,导致寄存器溢出,性能直接腰斩。

怎么避免?减少每个线程的变量数量,或者用共享内存替代部分局部变量。

常量内存(Constant Memory)

常量内存是GPU里一个特殊的存在。它容量很小(通常64KB),但有两个优势:

  • 有专用的缓存(constant cache)
  • 支持广播——同一个warp内的线程访问同一地址时,只需要一次内存事务

适用场景:存储不会改变的参数,比如卷积核的权重、查找表等。

__constant__ float filter[1024];  // 声明常量内存

__global__ void apply_filter(float* input, float* output) {
    int idx = threadIdx.x + blockIdx.x * blockDim.x;
    // 所有线程访问同一个filter元素时,效率极高
    output[idx] = input[idx] * filter[0] + input[idx+1] * filter[1];
}

使用建议:如果你的kernel里所有线程都访问同一个只读数据,优先考虑常量内存。我在做音频滤波时,把滤波器系数放在常量内存里,比放在全局内存快了将近3倍。

纹理内存(Texture Memory)

纹理内存最初是为图形学设计的,但在通用计算中也有独特价值。它提供了:

  • 硬件插值(适合图像缩放)
  • 边界处理(clamp、wrap等模式)
  • 空间局部性优化(2D局部性比全局内存好)

不过说实话,在现代GPU架构中(Volta及以后),纹理内存的优势在逐渐减弱。因为全局内存的缓存机制越来越智能了。我个人现在只在需要硬件插值时才用纹理内存。

内存层次结构总览

下面这张图是我自己画的GPU内存层次结构,你可以直观地看到每一层的位置和关系:

GPU内存层次结构 全局内存 (Global Memory) 容量: GB级 | 延迟: 400~800周期 | 所有线程可访问 L2缓存 (L2 Cache) 所有SM共享 | 容量: 几MB 流多处理器 (SM) 共享内存 48~164KB | 20~40周期 常量内存缓存 64KB | 支持广播 寄存器 1周期 | 每个线程私有 本地内存 寄存器溢出 | 高延迟 纹理内存 (Texture Memory) - 硬件插值/边界处理 延迟: 寄存器 < 共享内存 < L2 < 全局内存 容量: 寄存器 < 共享内存 < L2 < 全局内存

内存访问模式对性能的影响

理解了内存层次结构,我们来看看实际中怎么用。我总结了几个关键原则:

  1. 合并访问是王道:全局内存访问一定要合并,这是性能的第一道门槛。
  2. 数据重用用共享内存:如果同一个数据要被多个线程使用,先搬到共享内存。
  3. 避免寄存器溢出:控制每个线程的变量数量,保持occupancy。
  4. 常量数据用常量内存:所有线程访问同一只读数据时,常量内存效率最高。
  5. 注意bank conflict:共享内存有32个bank,同时访问同一bank的不同地址会导致冲突。

实战经验:我在优化一个3D卷积网络时,最初直接读写全局内存,batch size=1时延迟是120ms。后来做了三步优化:

  • 把输入特征图分块加载到共享内存
  • 把卷积核参数放到常量内存
  • 调整线程块大小避免寄存器溢出

最终延迟降到了18ms。嗯,这就是理解内存层次结构带来的回报。

最后说一句:GPU编程的本质,就是管理数据流动。算力再强,数据喂不进去也是白搭。希望今天的内容能帮你建立起这个思维框架。

课后练习:找一个你之前写过的CUDA kernel,用nvcc --ptxas-options=-v编译,看看寄存器使用量和本地内存使用量。然后尝试优化一下,看看性能能提升多少。

想要更多资料,联系可可