第三章:CUDA内存模型——全局内存、共享内存、寄存器、本地内存、常量内存、纹理内存

说到CUDA编程,内存模型这块儿,我敢说——谁搞懂了内存,谁就掌握了性能的钥匙。

你想想看,GPU里有成千上万个线程同时跑。它们怎么读写数据?数据存在哪儿?读写快不快?这些问题,全跟内存模型有关。我刚开始学CUDA那会儿,就踩过一个坑:写了个核函数,逻辑完全正确,结果跑起来比CPU还慢。后来一查,问题出在全局内存的访问模式上。嗯,从那以后,我再也不敢轻视内存模型了。

今天我们就来拆解CUDA的六大内存类型。我会结合项目经验,告诉你每种内存该怎么用、什么时候用、千万别怎么用。

3.1 全局内存(Global Memory)

全局内存是GPU里最大的存储空间,所有线程都能访问。说白了,它就是GPU的“主存”。

特点:

  • 容量大:一般几个GB到几十个GB
  • 延迟高:访问一次大概400-800个时钟周期
  • 所有线程(包括CPU)都能读写

我个人的习惯是:能用共享内存解决的问题,绝不用全局内存。但数据量太大时,全局内存是唯一的选择。

关键优化点:合并访问(Coalesced Access)

全局内存的性能,很大程度上取决于访问模式。如果相邻线程访问相邻的地址,硬件会把多次访问合并成一次。这叫“合并访问”。

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

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

我在项目中遇到过:一个图像处理核函数,原本用非合并访问,带宽利用率只有15%。改成合并访问后,直接飙到85%。性能提升了5倍多。你想想看,就改了一行代码的事。

避坑指南

我曾经在项目里用全局内存存储临时变量,结果发现性能惨不忍睹。后来才意识到:临时变量应该用寄存器或本地内存。全局内存是给“大数据”用的,不是给“小变量”用的。

3.2 共享内存(Shared Memory)

共享内存是CUDA编程的“杀手锏”。它位于芯片内部,延迟极低(大约20-30个时钟周期),比全局内存快一个数量级。

特点:

  • 容量小:每个block通常只有几十KB
  • 速度快:接近寄存器速度
  • 同一block内的线程共享
  • 需要手动管理数据搬入搬出

说白了,共享内存就是程序员手里的“缓存”。你得自己决定把哪些数据放进去,什么时候放进去。

// 使用共享内存做矩阵转置
__global__ void transpose_kernel(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();  // 同步!确保所有线程都加载完毕
    
    // 从共享内存写回全局内存(转置)
    out[x * width + y] = tile[threadIdx.y][threadIdx.x];
}

我的经验

使用共享内存时,__syncthreads() 这个同步函数特别重要。我曾经漏掉它,结果数据还没加载完就开始计算,算出来的结果全是错的。调试了整整一天才找到问题。记住:共享内存没有硬件自动同步,全靠程序员自己把控。

3.3 寄存器(Register)

寄存器是GPU里最快的内存,没有之一。每个线程都有自己的寄存器,其他线程访问不到。

特点:

  • 速度:零延迟(一个时钟周期内完成)
  • 容量:每个线程最多255个寄存器(不同架构有差异)
  • 私有:只有当前线程能访问

你写的局部变量,只要编译器觉得合适,就会放到寄存器里。但要注意:寄存器数量是有限的。如果每个线程用的寄存器太多,就会发生“寄存器溢出”——数据被挤到本地内存里,性能骤降。

寄存器溢出有多可怕?

我优化过一个计算密集型的核函数。原本每个线程用了80个寄存器,性能一般。我重构了代码,把一些中间结果复用,寄存器降到48个。结果occupancy从25%升到50%,性能翻了一倍。

3.4 本地内存(Local Memory)

本地内存听起来像“本地”的,其实它跟全局内存是同一块物理存储。只是编译器把它伪装成“每个线程私有”的样子。

什么时候会用本地内存?

  • 寄存器不够用的时候(寄存器溢出)
  • 访问大型数组或结构体
  • 编译器无法确定索引的数组访问

嗯,这里要注意:本地内存的延迟跟全局内存一样高。所以尽量避免使用它。怎么避免?少用局部数组,多用标量变量。

避坑指南

我曾经写了一个核函数,里面定义了一个 float arr[100] 的局部数组。编译器把它放到了本地内存里,每次访问都要400多个时钟周期。后来我改成用共享内存,性能提升了10倍。记住:大局部数组 = 性能杀手。

3.5 常量内存(Constant Memory)

常量内存是专门存“只读数据”的。它有两大特点:一是只读,二是带缓存。

特点:

  • 容量:64KB(所有架构都一样)
  • 速度:如果所有线程访问同一个地址,几乎跟寄存器一样快
  • 缓存:硬件会自动缓存常量数据
// 声明常量内存
__constant__ float filter[256];

// 主机端初始化
cudaMemcpyToSymbol(filter, host_filter, sizeof(float) * 256);

// 核函数中使用
__global__ void apply_filter_kernel(float *data) {
    int tid = threadIdx.x + blockIdx.x * blockDim.x;
    data[tid] = data[tid] * filter[tid % 256];  // 所有线程访问filter
}

我建议:当你有只读数据,而且所有线程都访问相同地址时,优先考虑常量内存。比如卷积核的权重、查找表等。

3.6 纹理内存(Texture Memory)

纹理内存最初是为图形处理设计的,但在通用计算里也很有用。它也有缓存,而且支持一些特殊功能。

特点:

  • 有缓存:对空间局部性好的访问模式很友好
  • 支持插值:硬件自动做线性插值
  • 支持边界处理:可以设置“超出边界返回0”等模式
  • 适合二维/三维数据访问

说实话,纹理内存在通用计算里用得不多。但在图像处理、科学计算(比如网格插值)这些场景下,它比全局内存快不少。

我的经验

我在做医学图像重建时,用纹理内存做体数据的三线性插值。相比手动实现插值,纹理内存的硬件插值不仅代码简洁,速度还快了30%。如果你做的是图像或体数据处理,纹理内存值得一试。

3.7 内存模型全景图

说了这么多,我们来画一张图,把六种内存的关系理清楚。

CUDA内存模型全景图 全局内存(Global Memory) 容量:GB级 | 延迟:400-800周期 | 所有线程可访问 常量内存 64KB | 只读 带缓存 所有线程可读 纹理内存 只读 | 带缓存 支持插值/边界处理 适合2D/3D访问 Block内的共享资源 共享内存 几十KB | 20-30周期 同一Block内线程共享 每个线程私有 寄存器(最快,零延迟) 本地内存(慢,寄存器溢出时用) 常量缓存 纹理缓存 数据搬入 全局内存 Block内共享 线程私有 常量/纹理(带缓存)

3.8 总结与选择策略

好了,六种内存都讲完了。怎么选?我总结了一个简单的决策流程:

  1. 数据是只读的吗? → 是 → 所有线程访问相同地址?→ 是 → 用常量内存
  2. 数据是只读的吗? → 是 → 需要插值或边界处理?→ 是 → 用纹理内存
  3. 数据需要被同一Block的线程共享吗? → 是 → 用共享内存
  4. 数据是临时变量,且很小? → 用寄存器(注意不要溢出)
  5. 其他情况 → 用全局内存,但注意合并访问

性能对比速查表

内存类型 延迟 容量 作用域 缓存
寄存器 1周期 ~255个/线程 线程私有 无
共享内存 20-30周期 ~48KB/Block Block内 无(手动管理)
常量内存 1周期(缓存命中) 64KB 全局 有
纹理内存 1周期(缓存命中) 同全局内存 全局 有
本地内存 400-800周期 同全局内存 线程私有 无(L1/L2)
全局内存 400-800周期 GB级 全局 L2缓存

最后说一句:内存模型是CUDA性能优化的基石。你花一周时间搞懂它,后面优化代码时能省下一个月的时间。我当年就是吃了这个亏,走了不少弯路。希望你能少踩这些坑。

我的建议

刚开始学的时候,别急着优化。先写一个能跑通的版本,然后用 nvcc --ptxas-options=-v 查看寄存器使用情况。如果发现寄存器溢出,再考虑用共享内存或调整代码结构。一步一步来,性能自然就上去了。

可可精品资料库