CUDA内存模型全景:从全局内存到寄存器,一张图看懂GPU存储层次

做GPU优化的人,心里都有一张内存层次图。我刚开始接触CUDA时,最头疼的就是搞不清各种内存的区别。全局内存、共享内存、寄存器、常量内存、纹理内存……名字一大堆,用起来更是容易踩坑。

说白了,GPU的内存模型就像一座金字塔。越往上,速度越快,容量越小。越往下,速度越慢,容量越大。今天我就带你把这个金字塔彻底搞明白。

核心观点:CUDA优化的本质,就是让数据尽可能待在金字塔的上层。数据离计算单元越近,程序跑得越快。

一、GPU存储层次全景图

先上一张我自己画的图。这张图我用了好多年,每次给团队新人培训都会拿出来讲。你把它存下来,以后写代码时对照着看,思路会清晰很多。

GPU存储层次金字塔 全局内存 (Global Memory) 容量: GB级 | 延迟: 400-800 cycles 常量内存 / 纹理内存 容量: 64KB / 依赖设备 | 延迟: 200-400 cycles L2 缓存 容量: 40KB-6MB | 延迟: 100-200 cycles 共享内存 (Shared Memory) 容量: 48KB-164KB/block | 延迟: ~30 cycles L1 缓存 容量: 24KB-128KB/SM | 延迟: ~30 cycles 寄存器 容量: 256KB/SM | 延迟: 1 cycle 速度递减 → 容量递增 最快,最小 最慢,最大

这张图里,我特意把寄存器放在最顶端。为什么?因为寄存器是GPU里最快的存储单元,访问延迟只有1个时钟周期。而最底层的全局内存,延迟高达400-800个周期。你想想看,这中间差了将近三个数量级。

二、逐层拆解:每层内存的脾气秉性

1. 寄存器(Register)

寄存器是每个线程私有的。说白了,你在kernel里定义的局部变量,只要编译器没优化掉,基本都放在寄存器里。

我的经验:我曾经在一个矩阵乘法的项目里,因为每个线程用了太多寄存器,导致occupancy从100%掉到了33%。性能直接腰斩。后来我手动拆分了变量,把一些中间结果放到共享内存里,occupancy才拉回来。

寄存器数量是有限的。每个SM有65536个寄存器(以A100为例),分配到每个线程最多255个。如果你每个线程用了超过这个数,寄存器就会溢出到局部内存——那玩意儿其实在全局内存里,慢得要命。

避坑指南:我曾经见过一个新手,在kernel里定义了一个巨大的结构体数组,结果编译器默默地把所有变量都 spill 到了局部内存。性能惨不忍睹。记得用 --ptxas-options=-v 编译选项查看寄存器使用情况。

2. L1缓存 / 共享内存

这两个东西在硬件上共用同一块物理存储。你可以通过API配置它们的比例。比如在Turing架构上,可以配成48KB共享内存+16KB L1,或者反过来。

共享内存是块上所有线程共享的。它的速度跟L1差不多,但它是可编程的。你可以手动控制数据什么时候加载进来,什么时候写回去。

关键区别:L1是硬件自动管理的,共享内存是程序员手动管理的。手动管理虽然麻烦,但你能精确控制数据复用,这是优化的核心。

3. L2缓存

L2缓存是所有SM共享的。它缓存全局内存和局部内存的访问。在A100上,L2缓存有40MB,已经相当大了。

但要注意,L2缓存是透明的。你没法直接控制它。不过你可以通过调整数据访问模式来利用它。比如,让同一个warp里的线程访问连续的地址,这样L2缓存的命中率会高很多。

4. 常量内存 / 纹理内存

常量内存只有64KB,但它的读取有专门的缓存和广播机制。如果同一个warp里的所有线程读取同一个常量地址,那只需要一次内存事务,速度极快。

纹理内存呢,它专门为空间局部性优化过。如果你在做图像处理或者需要插值计算,纹理内存比全局内存快不少。

我个人的习惯:凡是只读且所有线程访问相同数据的参数,我都用常量内存。比如卷积核的权重、矩阵乘法的系数。64KB虽然小,但放这些参数绰绰有余。

5. 全局内存

全局内存是GPU里最大的存储空间,也是速度最慢的。所有线程都能访问它,但每次访问都要付出几百个周期的代价。

优化全局内存访问的核心原则就一条:合并访问。也就是让相邻的线程访问相邻的地址。这样GPU可以把多次内存请求合并成一次大的事务,效率高得多。

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

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

三、一张表总结所有内存特性

内存类型 作用域 生命周期 延迟 容量 缓存
寄存器 单个线程 kernel执行期间 1 cycle 256KB/SM 无
L1缓存 单个SM 自动管理 ~30 cycles 24-128KB/SM 硬件控制
共享内存 单个Block kernel执行期间 ~30 cycles 48-164KB/Block 可编程
L2缓存 所有SM 自动管理 100-200 cycles 40KB-40MB 硬件控制
常量内存 所有线程 程序运行期间 ~200 cycles 64KB 专用缓存
纹理内存 所有线程 程序运行期间 ~200 cycles 依赖设备 专用缓存
全局内存 所有线程 程序运行期间 400-800 cycles GB级 L1+L2

四、实际项目中的内存选择策略

好了,理论讲完了。咱们来点实际的。你在写一个kernel时,怎么决定数据放哪?

我一般按这个顺序思考:

  1. 先看数据是不是只读且所有线程共享 → 放常量内存(64KB以内)或纹理内存
  2. 再看数据是不是被同一个block多次复用 → 放共享内存,手动管理
  3. 如果数据只被单个线程使用 → 尽量用寄存器,别用局部内存
  4. 剩下的,放全局内存 → 但一定要保证合并访问

一个真实的案例:我之前优化过一个流体力学模拟程序。原始版本把所有数据都放在全局内存里,每个线程反复读取同一块数据。我把它改成:把每个block需要的子区域加载到共享内存,计算完再写回全局内存。结果性能提升了4倍。说白了,就是让数据在金字塔上层多待一会儿。

五、常见误区与避坑

  • 误区一:寄存器越多越好 —— 错!寄存器太多会降低occupancy,反而更慢。我建议每个线程控制在32个寄存器以内。
  • 误区二:共享内存越大越好 —— 错!共享内存用太多,每个SM能同时运行的block数就少了。要平衡。
  • 误区三:全局内存随便访问 —— 错!不合并访问的全局内存,性能可能差10倍以上。
  • 误区四:L2缓存不用管 —— 错!虽然你不能直接控制L2,但数据访问模式会影响L2命中率。尽量让数据访问有规律。

我曾经踩过的坑:有一次我为了省事,把所有数据都声明为 __device__ 全局变量。结果每个线程都去读写同一个地址,导致严重的bank conflict和缓存抖动。后来改成每个线程处理自己的数据分区,问题才解决。

嗯,到这里,CUDA内存模型的全景图你应该心里有数了。记住那张金字塔图,写代码时多想想:我的数据现在在金字塔的哪一层?能不能往上挪一挪?

想通了这个问题,你的CUDA优化之路就成功了一半。

可可精品资料库