第三章: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 内存模型全景图
说了这么多,我们来画一张图,把六种内存的关系理清楚。
3.8 总结与选择策略
好了,六种内存都讲完了。怎么选?我总结了一个简单的决策流程:
- 数据是只读的吗? → 是 → 所有线程访问相同地址?→ 是 → 用常量内存
- 数据是只读的吗? → 是 → 需要插值或边界处理?→ 是 → 用纹理内存
- 数据需要被同一Block的线程共享吗? → 是 → 用共享内存
- 数据是临时变量,且很小? → 用寄存器(注意不要溢出)
- 其他情况 → 用全局内存,但注意合并访问
性能对比速查表
| 内存类型 | 延迟 | 容量 | 作用域 | 缓存 |
|---|---|---|---|---|
| 寄存器 | 1周期 | ~255个/线程 | 线程私有 | 无 |
| 共享内存 | 20-30周期 | ~48KB/Block | Block内 | 无(手动管理) |
| 常量内存 | 1周期(缓存命中) | 64KB | 全局 | 有 |
| 纹理内存 | 1周期(缓存命中) | 同全局内存 | 全局 | 有 |
| 本地内存 | 400-800周期 | 同全局内存 | 线程私有 | 无(L1/L2) |
| 全局内存 | 400-800周期 | GB级 | 全局 | L2缓存 |
最后说一句:内存模型是CUDA性能优化的基石。你花一周时间搞懂它,后面优化代码时能省下一个月的时间。我当年就是吃了这个亏,走了不少弯路。希望你能少踩这些坑。
我的建议
刚开始学的时候,别急着优化。先写一个能跑通的版本,然后用 nvcc --ptxas-options=-v 查看寄存器使用情况。如果发现寄存器溢出,再考虑用共享内存或调整代码结构。一步一步来,性能自然就上去了。