2、GPU内存层次结构:全局内存、共享内存、寄存器、常量内存、纹理内存的对比

说到GPU编程,内存管理永远是绕不开的核心话题。我经常跟团队里的新人说:你写CUDA代码的水平,很大程度上取决于你对内存层次的理解深度。为什么?因为数据在GPU里怎么流动、存在哪里,直接决定了你的程序能跑多快。

这一章,咱们就把GPU的五种主要内存掰开揉碎了讲清楚。我会结合自己踩过的坑,帮你建立起清晰的内存使用直觉。

2.1 一张图看懂全局架构

先看下面这张图,它概括了GPU内存的完整层次结构。我建议你把它当作「地图」来用——写代码时随时对照,就知道数据该放哪了。

GPU内存层次结构总览 全局内存 (Global Memory) 容量大(GB级)· 延迟高(400-800 cycles)· 所有线程可访问 常量内存 (Constant Memory) 只读· 64KB· 有缓存 适合:所有线程读相同数据 延迟:~10 cycles(缓存命中) 纹理内存 (Texture Memory) 只读· 有缓存· 支持插值 适合:空间局部性访问 延迟:~100 cycles(缓存命中) 共享内存 (Shared Memory) 容量小(48-164KB/block)· 低延迟(~30 cycles)· 线程块内共享 寄存器 (Registers) 最快(0 cycles)· 线程私有· 256K/block 速度↑ 容量↑

从这张图你能看到:越靠近计算单元(SM)的内存,速度越快,但容量越小。这是个经典的「容量-速度」权衡问题。我刚开始做CUDA时,总想把所有数据都塞进共享内存,结果发现根本装不下——这就是没理解层次结构的代价。

2.2 五种内存逐一拆解

2.2.1 全局内存——最常用,也最慢

全局内存是GPU里的大仓库。所有线程都能读写,容量通常有8GB、16GB甚至更大。但代价是延迟高得吓人——一次访问要400到800个时钟周期。

我个人习惯把全局内存比作「硬盘」:能存很多东西,但每次读写都像在等快递。你想想看,如果你的核心循环里频繁访问全局内存,那性能基本就交代了。

关键点:全局内存访问必须满足「合并访问」条件才能发挥带宽。简单说就是:相邻线程访问相邻地址。我见过太多人因为没注意这个,导致带宽利用率只有10%。

我的经验:能用共享内存做数据复用的地方,就别反复读全局内存。我曾经优化过一个矩阵乘法,仅仅是把数据先搬进共享内存,性能就提升了8倍。

2.2.2 共享内存——程序员可控的缓存

共享内存是每个线程块私有的高速存储区。延迟只有30个周期左右,比全局内存快了一个数量级。但它有两个限制:容量小(通常48KB到164KB),而且线程块内共享。

共享内存最经典的用法就是做数据复用。比如做卷积运算时,每个线程需要访问输入数据的某个窗口。如果每个线程都去全局内存读,那就太蠢了。正确的做法是:先把整个窗口的数据加载到共享内存,然后所有线程从共享内存读。

// 共享内存典型用法:数据分块加载
__global__ void kernel(float* input, float* output, int N) {
    __shared__ float s_data[BLOCK_SIZE];  // 声明共享内存
    
    int tid = threadIdx.x;
    int idx = blockIdx.x * blockDim.x + tid;
    
    // 从全局内存加载到共享内存
    if (idx < N) {
        s_data[tid] = input[idx];
    }
    __syncthreads();  // 同步!确保所有线程加载完成
    
    // 从共享内存读取,进行计算
    // ... 这里做你的核心计算 ...
    
    // 写回全局内存
    if (idx < N) {
        output[idx] = s_data[tid] * 2.0f;
    }
}

注意:使用共享内存时,__syncthreads() 是必须的。我曾经因为忘记加这个同步,导致线程A读到的数据还是上一轮计算的结果——debug了整整两天才发现。

2.2.3 寄存器——最快,但最稀缺

寄存器是GPU里最快的存储,访问延迟为0个周期。每个线程都有自己的私有寄存器,但数量非常有限——每个线程最多255个寄存器(不同架构略有差异)。

你可能会想:「寄存器这么快,那我多用点不就好了?」嗯,这里要注意:寄存器用多了,会降低SM上同时运行的线程数量。比如一个SM最多能跑2048个线程,但如果每个线程用了32个寄存器,那SM就只能跑1024个线程了。这叫「寄存器压力」。

我记得有一次优化一个深度学习算子,为了省寄存器,我把一些中间结果拆成多次计算。虽然计算量增加了,但因为SM上能同时跑的线程数翻倍了,最终性能反而提升了30%。这就是权衡的艺术。

2.2.4 常量内存——只读,但有缓存

常量内存是全局内存的一个特殊区域,大小只有64KB。它有两个特点:只读,而且有专用的缓存。当所有线程都读取同一个地址时,常量内存的效率极高——一次广播就能搞定。

常量内存最适合放那些「所有线程都用,但不会变」的数据。比如神经网络的权重、物理模拟中的常数参数等。

避坑指南:我曾经把一组查找表放在常量内存里,结果发现性能还不如全局内存。后来一查才知道:常量内存的缓存只有一次广播能力。如果不同线程读不同地址,那就退化成串行访问了。所以,常量内存只适合「所有线程读同一个地址」的场景。

2.2.5 纹理内存——为空间局部性而生

纹理内存最初是为图形渲染设计的,但在通用计算里也很有用。它也是只读的,但有专门的缓存机制,特别适合二维或三维的空间局部性访问。

什么叫空间局部性?就是相邻的线程访问相邻的地址。比如图像处理中,每个像素需要读取它周围3x3邻域的数据。这种情况下,纹理内存的缓存命中率非常高。

纹理内存还有一个绝活:硬件插值。如果你需要做双线性插值或三线性插值,纹理内存可以直接帮你算好,省掉一堆计算指令。

2.3 五种内存的对比表格

下面这个表格是我自己整理的,写代码时经常拿出来对照。你最好也记住这些关键数字。

内存类型 作用域 生命周期 读写权限 延迟 典型容量 缓存
全局内存 所有线程 程序运行期间 读/写 400-800 cycles GB级 L1/L2
共享内存 线程块内 内核执行期间 读/写 ~30 cycles 48-164 KB 无
寄存器 单个线程 内核执行期间 读/写 0 cycles 256 KB/block 无
常量内存 所有线程 程序运行期间 只读 ~10 cycles(缓存命中) 64 KB 专用缓存
纹理内存 所有线程 程序运行期间 只读 ~100 cycles(缓存命中) 取决于分配 专用缓存

2.4 实战中的选择策略

说了这么多理论,到底怎么选?我总结了一个简单的决策流程:

  1. 数据是否只读? → 是 → 考虑常量内存或纹理内存
  2. 数据是否被线程块内多个线程复用? → 是 → 用共享内存
  3. 数据是否每个线程私有且访问频繁? → 是 → 尽量用寄存器
  4. 以上都不满足? → 用全局内存,但注意合并访问

这个流程不是绝对的,但能帮你快速定位。我刚开始做优化时,就是靠这个流程一步步排查,很少走弯路。

一个小技巧:用 nvcc --ptxas-options=-v 编译时,会输出每个内核的寄存器使用量和共享内存使用量。我每次写完内核都会跑一下这个命令,看看资源使用是否合理。

2.5 一个综合示例

最后,给你看一个综合使用多种内存的代码片段。这是一个简化版的图像卷积核:

__constant__ float c_kernel[9];  // 卷积核放在常量内存

__global__ void convolution_2d(float* input, float* output, int width, int height) {
    __shared__ float s_tile[TILE_SIZE + 2][TILE_SIZE + 2];  // 共享内存存图块
    
    int tx = threadIdx.x;
    int ty = threadIdx.y;
    int gx = blockIdx.x * TILE_SIZE + tx;
    int gy = blockIdx.y * TILE_SIZE + ty;
    
    // 加载数据到共享内存(包含边界填充)
    // ... 这里省略边界处理逻辑 ...
    s_tile[ty + 1][tx + 1] = input[gy * width + gx];
    __syncthreads();
    
    // 计算卷积(从共享内存读,用常量内存的卷积核)
    if (gx < width && gy < height) {
        float sum = 0.0f;
        for (int i = 0; i < 3; i++) {
            for (int j = 0; j < 3; j++) {
                sum += s_tile[ty + i][tx + j] * c_kernel[i * 3 + j];
            }
        }
        output[gy * width + gx] = sum;
    }
}

这个例子中:卷积核放常量内存(所有线程读相同数据),图像块放共享内存(线程块内复用),中间变量用寄存器(编译器自动处理)。三种内存各司其职,性能自然就上去了。

好了,关于GPU内存层次结构就讲到这里。记住一句话:没有最好的内存,只有最合适的用法。下一章咱们会深入共享内存的bank conflict问题——那才是真正考验功力的地方。

想要更多资料,联系可可