4. CUDA内存模型:全局内存、共享内存、寄存器、本地内存、常量内存、纹理内存的特点与使用场景

说到CUDA编程,内存模型是绕不开的核心话题。我刚开始学的时候,觉得不就是几种内存嘛,用就是了。结果写出来的程序跑得比CPU还慢,被导师骂了一顿。后来才明白——不理解内存模型,你写的CUDA代码就是碰运气

CUDA的内存种类挺多的,但别怕。咱们一个一个捋清楚。每种内存都有它的脾气,用对了地方,性能起飞;用错了,嗯,你会怀疑人生的。

一句话总结:CUDA内存优化的本质,就是把数据放在离计算单元最近的地方,尽量减少慢速内存的访问次数。

CUDA内存层次结构 线程 (Thread) 寄存器 (Register) 本地内存 (Local) 线程块 (Block) 共享内存 (Shared Memory) 网格 (Grid) 全局内存 (Global) 常量内存 (Constant) 纹理内存 (Texture) 线程私有 块内共享 全局可见

4.1 全局内存(Global Memory)

全局内存是GPU里最大的存储空间,所有线程都能访问。说白了,它就像你的硬盘——容量大,但慢。

特点:

  • 容量最大,通常几个GB到几十个GB
  • 延迟最高,大约400-800个时钟周期
  • 所有线程(包括CPU)都能读写
  • 没有缓存(或者说L2缓存很小)

使用场景:

  • 存放输入数据和最终结果
  • 线程间需要交换数据时(但效率低)
  • 数据量太大,放不进其他内存时

我的经验:全局内存访问要尽量合并。什么意思呢?就是相邻线程访问相邻地址。我见过一个项目,就因为没注意合并访问,性能差了10倍。后来改成连续访问,速度立马就上来了。

// 好的访问模式:合并访问
__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.x * 100 + blockIdx.x;
    float val = data[idx];  // 线程之间隔了100个元素
}

4.2 共享内存(Shared Memory)

共享内存是CUDA编程的杀手锏。它位于芯片内部,速度极快,但容量很小(通常几十KB)。同一个线程块内的线程可以共享这块内存。

特点:

  • 延迟极低,大约1-2个时钟周期
  • 容量小,每个SM通常只有几十KB
  • 同一个block内的线程可以读写
  • 需要手动管理数据加载和同步

使用场景:

  • 数据重用:多个线程需要反复访问同一块数据
  • 线程间协作:比如做归约、扫描等操作
  • 全局内存的缓存:先把数据从全局内存搬到共享内存

核心技巧:共享内存最怕的就是bank conflict。我踩过这个坑——明明用了共享内存,性能反而下降了。后来一查,是bank conflict导致串行化访问。解决办法就是加padding或者调整数据布局。

// 共享内存做矩阵转置的经典用法
__global__ void transpose(float *in, float *out, int width) {
    __shared__ float tile[TILE_SIZE][TILE_SIZE];
    
    int x = blockIdx.x * TILE_SIZE + threadIdx.x;
    int y = blockIdx.y * TILE_SIZE + threadIdx.y;
    
    // 加载到共享内存
    tile[threadIdx.y][threadIdx.x] = in[y * width + x];
    __syncthreads();  // 同步!别忘了
    
    // 从共享内存读出并转置
    x = blockIdx.y * TILE_SIZE + threadIdx.x;
    y = blockIdx.x * TILE_SIZE + threadIdx.y;
    out[y * width + x] = tile[threadIdx.x][threadIdx.y];
}

4.3 寄存器(Register)

寄存器是GPU里最快的内存,没有之一。每个线程独享,编译器自动分配。但数量有限——一个SM的寄存器总数是固定的,线程越多,每个线程分到的就越少。

特点:

  • 零延迟,一个时钟周期就能访问
  • 每个线程私有,编译器自动管理
  • 数量有限,每个线程最多255个(不同架构有差异)
  • 寄存器溢出时会使用本地内存(性能骤降)

使用场景:

  • 局部变量、循环计数器、临时计算结果
  • 不需要在线程间共享的数据

注意:我曾经犯过一个错误——在kernel里声明了太多局部变量,导致寄存器溢出。编译器默默地把这些变量放到了本地内存里,性能直接腰斩。后来我用--maxrregcount编译选项限制寄存器数量,反而让occupancy上去了,性能提升了30%。

4.4 本地内存(Local Memory)

本地内存听起来像是片上的,其实不是。它和全局内存一样在显存里,只是每个线程私有的。编译器在寄存器不够用的时候,会把变量溢出到这里。

特点:

  • 延迟高,和全局内存差不多
  • 每个线程私有,其他线程看不到
  • 编译器自动使用,开发者一般不需要手动管理
  • 通常用于数组索引访问或者寄存器溢出

使用场景:

  • 大型局部数组(比如声明了一个float arr[100]
  • 编译器判断寄存器不够用时

避坑指南:怎么知道有没有用本地内存?编译时加--ptxas-options=-v选项,看输出里的lmem字段。如果数值很大,说明寄存器溢出了,得优化代码。

4.5 常量内存(Constant Memory)

常量内存是全局内存的一种特殊形式,但它有硬件缓存(常量缓存)。适合存放那些不会改变、且所有线程都需要的只读数据。

特点:

  • 有专门的常量缓存,访问速度快
  • 容量小,通常64KB
  • 只读,所有线程访问同一地址
  • 同一个warp内的线程访问同一地址时,速度最快

使用场景:

  • 查找表、系数、参数等只读数据
  • 所有线程都需要访问的固定数据
// 常量内存声明
__constant__ float coeff[256];

// 主机端初始化
float h_coeff[256] = { ... };
cudaMemcpyToSymbol(coeff, h_coeff, sizeof(h_coeff));

// kernel中使用
__global__ void apply_filter(float *data) {
    int idx = threadIdx.x + blockIdx.x * blockDim.x;
    data[idx] = data[idx] * coeff[idx % 256];  // 所有线程访问同一地址
}

我的建议:常量内存最适合warp内所有线程访问同一地址的场景。如果每个线程访问不同地址,常量缓存反而会串行化,性能还不如全局内存。我做过一个滤波器,一开始用常量内存存系数,结果每个线程访问不同系数,慢得要命。后来改成共享内存,速度翻倍。

4.6 纹理内存(Texture Memory)

纹理内存最初是为图形处理设计的,但在通用计算里也有妙用。它有自己的缓存,支持硬件插值和边界处理。

特点:

  • 有纹理缓存,对空间局部性好的访问模式友好
  • 支持硬件线性插值(做图像处理时很爽)
  • 支持边界处理(clamp、wrap等模式)
  • 只读

使用场景:

  • 图像处理、计算机视觉
  • 需要硬件插值的科学计算
  • 非对齐访问模式(纹理缓存能处理)

我记得:有一次做医学图像重建,数据访问模式非常随机,全局内存的缓存命中率极低。换成纹理内存后,利用它的2D空间局部性缓存,性能提升了3倍。纹理内存对那种"邻居访问"模式特别友好。

4.7 各内存对比一览

内存类型 位置 延迟 容量 作用域 生命周期
寄存器 片上 ~1 cycle ~256个/线程 线程私有 kernel执行期间
共享内存 片上 ~2 cycles ~48KB/SM block内共享 block执行期间
本地内存 显存 ~500 cycles 受显存限制 线程私有 kernel执行期间
全局内存 显存 ~500 cycles GB级别 所有线程+CPU 程序运行期间
常量内存 显存+缓存 ~5 cycles(缓存命中) 64KB 所有线程只读 程序运行期间
纹理内存 显存+缓存 ~100 cycles(缓存命中) 受显存限制 所有线程只读 程序运行期间

4.8 内存选择策略

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

  1. 数据是否只读且所有线程访问同一地址? → 常量内存
  2. 数据是否只读且访问模式不规则? → 纹理内存
  3. 数据是否需要在线程块内共享? → 共享内存
  4. 数据是否线程私有且量小? → 寄存器
  5. 其他情况? → 全局内存

最后提醒一句:别为了用共享内存而用共享内存。我见过有人把只读一次的数据也搬到共享内存里,结果多了两次拷贝,反而更慢。共享内存的精髓在于数据重用——同一个数据被多个线程多次访问,这时候用共享内存才划算。

好了,CUDA的六种内存就讲到这里。每种内存都有它的脾气,用对了就是利器,用错了就是坑。多写代码多踩坑,慢慢就找到感觉了。


公众号:蓝海资料掘金营,微信deep3321