一、CUDA内存模型全景:一张图看懂层级结构
做GPU加速这些年,我见过最多的坑就是显存溢出。说白了,很多人把GPU当成一个黑盒子,数据往里一扔就完事。结果呢?要么跑着跑着崩了,要么性能惨不忍睹。
要真正用好CUDA,你得先搞清楚GPU的内存长什么样。嗯,今天我们就来彻底拆解一下。
1.1 为什么内存模型这么重要?
你想想看,CPU访问内存,大概几十纳秒。GPU呢?全局内存访问延迟是几百个时钟周期。差了一个数量级还不止。
我在项目中遇到过这样一个场景:一个简单的矩阵乘法,naive实现跑了一整天没出结果。后来优化了内存访问模式,十分钟搞定。差别在哪?就在内存。
核心观点:CUDA程序的性能瓶颈,90%都在内存访问上。计算本身很快,数据搬运才是大头。
1.2 六种内存类型,一张图看懂
先上干货。CUDA内存模型分六层,从快到慢、从少到多。我习惯用这张图来跟团队新人讲解:
这张图我每次培训必讲。从上到下,速度越来越慢,容量越来越大。寄存器最快但最少,全局内存最慢但最大。
1.3 逐层拆解
寄存器(Register)
这是GPU最快的内存,没有之一。每个线程独享,编译器自动分配。我见过新手写kernel,一上来就定义几十个大数组,结果寄存器溢出到本地内存,性能直接腰斩。
避坑指南:我曾经调试过一个kernel,发现占用率只有25%。查了半天,原来是每个线程用了太多寄存器。后来拆成多个小kernel,占用率直接拉到75%。记住:寄存器不是越多越好。
本地内存(Local Memory)
说白了就是寄存器的"溢出区"。当你的线程需要太多寄存器时,编译器会把一部分变量放到本地内存。嗯,这里要注意:本地内存物理上在全局内存里,所以访问很慢。
怎么避免?我个人的经验是:每个线程的局部变量控制在32个以内。超过这个数,就要考虑用共享内存来分担了。
共享内存(Shared Memory)
这是CUDA优化的核心武器。同一个线程块内的线程可以共享数据,延迟只有几个时钟周期。我在做矩阵分块乘法时,就是靠共享内存把性能提升了10倍。
关键点:共享内存需要手动管理。用好了是神器,用不好就是瓶颈。记得处理bank conflict,否则性能会大打折扣。
常量内存(Constant Memory)
只读的,所有线程都能访问。适合存放查找表、系数矩阵这类不变的数据。它有专门的缓存,如果所有线程访问同一个地址,效率极高。
为什么会这样?因为常量内存的缓存是广播机制。所有线程读同一个地址,一次搞定。但如果每个线程读不同地址,那就惨了,串行化。
纹理内存(Texture Memory)
这个用得相对少一些,但在图像处理、空间局部性强的场景下特别好用。它支持硬件插值和边界处理,做图像滤波时能省不少事。
我记得有一次做遥感图像处理,用纹理内存做双线性插值,比手动实现快了3倍。嗯,如果你做的是二维数据访问,可以考虑它。
全局内存(Global Memory)
GPU的主存,所有线程都能读写。容量最大,但延迟也最高。访问全局内存时,一定要遵循合并访问原则——相邻线程访问相邻地址。
警告:全局内存的随机访问是性能杀手。我曾经接手过一个项目,代码里到处都是随机索引的全局内存访问。重构后改成连续访问,速度提升了8倍。记住:顺序访问,合并访问,这是铁律。
1.4 各内存对比一览
| 内存类型 | 作用域 | 生命周期 | 延迟 | 典型大小 | 是否可缓存 |
|---|---|---|---|---|---|
| 寄存器 | 单个线程 | 线程内 | 1周期 | 256个/线程 | N/A |
| 本地内存 | 单个线程 | 线程内 | ~100周期 | 取决于寄存器溢出 | L1/L2 |
| 共享内存 | 线程块 | 块内 | ~5周期 | 48KB/块(可配置) | N/A |
| 常量内存 | 全局 | 应用程序 | ~100周期 | 64KB | 专用缓存 |
| 纹理内存 | 全局 | 应用程序 | ~100周期 | 取决于显存 | 专用缓存 |
| 全局内存 | 全局 | 应用程序 | 400-800周期 | GB级别 | L1/L2 |
1.5 我的优化原则
做了这么多年CUDA优化,我总结出三条原则:
- 能放寄存器不放共享内存,能放共享内存不放全局内存。数据越靠近计算单元越好。
- 全局内存访问必须合并。这是底线,不遵守的话性能没救。
- 共享内存要手动管理bank conflict。别偷懒,该做padding就做padding。
小技巧:用nvcc --ptxas-options=-v编译时,会打印出每个kernel用了多少寄存器、共享内存。我每次写完kernel都会看一眼这个输出,心里有个底。
好了,CUDA内存模型的全景就讲到这里。记住这张层级图,后面我们讲优化方案时,会反复用到这些概念。下一章我们深入全局内存,聊聊怎么写出高效的全局内存访问模式。