1、GPU内存全景概览:为什么GPU需要复杂的内存层次?从冯·诺依曼瓶颈到GPU的解决方案。
1.1 冯·诺依曼瓶颈——那个让所有架构师头疼的老问题
做GPU架构这么多年,我经常被问到:为什么GPU的内存层次搞得这么复杂?
要回答这个问题,得先聊聊冯·诺依曼架构那个老毛病——存储墙。
说白了,CPU和内存之间的速度差距越来越大。CPU每18个月性能翻倍(摩尔定律),但内存的访问延迟只改善了不到10%。这就好比一个超级跑车配了个自行车轮胎——CPU空转等待数据,性能根本发挥不出来。
我2010年刚入行时,一个L1 cache的访问延迟大约是2-3个时钟周期,而访问主存需要200-300个周期。这个差距到现在也没缩小多少。你想想看,一个线程发出内存请求后,要等几百个周期才能拿到数据,这期间ALU只能干瞪眼。
对于GPU来说,这个问题更严重。GPU有几千个计算核心,如果每个核心都去访问主存,那带宽再大也不够用。所以,GPU必须有一套复杂的内存层次结构,来缓解这个瓶颈。
核心矛盾:计算单元越来越快,但内存访问速度跟不上。GPU通过多层次存储结构,让数据尽量靠近计算单元,减少远距离访问的次数。
1.2 GPU内存层次结构全景图
先看一张我画的整体架构图,把GPU的内存层次理清楚。
这张图我画了好几次才满意。你看,从顶层的全局内存到底层的寄存器,每一层都在用空间换速度。越靠近计算单元,容量越小但速度越快。
1.3 每一层都在解决什么问题?
咱们一层层往下看。
全局内存(Global Memory)
这是GPU最大的存储空间,通常有4GB到80GB不等。所有线程都能访问,但延迟也最高——大约400到800个时钟周期。
我做过一个矩阵乘法的优化项目,一开始直接读写全局内存,性能惨不忍睹。后来把数据搬到了共享内存,速度提升了将近10倍。这个教训让我深刻理解了:全局内存是最后的选择,不是第一选择。
注意:全局内存的访问要尽量合并(coalesced)。如果线程束(warp)内的32个线程访问连续的内存地址,硬件会把32次访问合并成一次大的传输。否则,性能会急剧下降。我曾经见过一个项目,因为没注意合并访问,性能差了5倍。
L2缓存(L2 Cache)
L2缓存是所有SM共享的。它的作用是过滤掉一部分全局内存的访问。如果多个SM访问同一块数据,L2缓存可以避免重复去全局内存取数据。
嗯,这里要注意:L2缓存是自动管理的,程序员不需要手动控制。但理解它的存在,能帮你更好地设计数据访问模式。
共享内存(Shared Memory)
这是GPU里最灵活、也最容易被误用的存储。共享内存位于SM内部,同一个线程块(block)内的所有线程可以共享数据。
它的延迟大约是30个周期,比全局内存快了一个数量级。但容量有限——每个SM通常只有几十KB到一百多KB。
我的经验:共享内存最适合做数据复用。比如做卷积运算时,输入数据会被多个输出像素重复使用。把数据先加载到共享内存,能大幅减少全局内存的访问次数。我习惯把共享内存看作「程序员手动控制的缓存」。
L1缓存(L1 Cache)
L1缓存和共享内存共享同一块物理存储。在NVIDIA的架构里,你可以通过编译器选项来调整它们的比例。比如,你可以配置48KB共享内存+16KB L1缓存,或者反过来。
L1缓存是自动管理的,主要缓存全局内存的访问。它的延迟和共享内存差不多,也是30个周期左右。
寄存器文件(Register File)
这是最快的存储,延迟只有1个周期。每个SM有大量的寄存器(比如256KB),但每个线程能用的寄存器数量有限。
我遇到过一个问题:一个kernel用了太多寄存器,导致每个SM能同时运行的线程数减少,最终影响了整体吞吐量。这就是所谓的「寄存器压力」。
关键指标:每个线程的寄存器使用量,直接影响occupancy(占用率)。占用率越高,GPU越能通过线程切换来隐藏内存延迟。一般建议每个线程不超过32个寄存器。
1.4 GPU如何用线程切换隐藏延迟?
你可能会问:既然内存延迟这么高,GPU为什么不把缓存做大一点?
答案很巧妙:GPU用线程切换来隐藏延迟,而不是靠大缓存。
CPU的做法是:把缓存做大,减少cache miss。GPU的做法是:让几千个线程同时运行,当一个线程在等数据时,立刻切换到另一个线程执行。只要线程数量足够多,计算单元就能一直忙碌。
说白了,GPU是用并行度来对抗延迟。这也是为什么GPU需要那么多寄存器——每个线程都需要保存自己的上下文,才能快速切换。
| 存储层次 | 典型容量 | 延迟(周期) | 管理方式 | 作用 |
|---|---|---|---|---|
| 寄存器 | 256KB/SM | 1 | 编译器分配 | 线程私有变量 |
| 共享内存 | 48-164KB/SM | ~30 | 程序员手动 | 线程块内数据共享 |
| L1缓存 | 16-128KB/SM | ~30 | 硬件自动 | 缓存全局内存访问 |
| L2缓存 | 几MB | ~200 | 硬件自动 | 跨SM数据共享 |
| 全局内存 | GB级 | ~400-800 | 程序员手动 | 所有数据存储 |
1.5 避坑指南:我踩过的那些内存坑
做了十几年GPU优化,我踩过的坑可以写本书了。这里分享几个典型的:
- 坑一:过度依赖全局内存。我曾经写过一个kernel,所有数据都从全局内存读,结果性能只有理论峰值的5%。后来改成用共享内存做数据复用,性能直接翻了8倍。
- 坑二:共享内存bank conflict。共享内存被分成32个bank,如果多个线程同时访问同一个bank的不同地址,就会发生冲突。我花了两天时间才定位到这个问题,最后通过调整数据布局解决了。
- 坑三:寄存器溢出。一个kernel用了太多寄存器,编译器把部分变量放到了本地内存(实际在全局内存里),性能直接腰斩。后来我手动限制了寄存器使用量,才恢复正常。
我的建议:写CUDA代码时,先用nvcc --ptxas-options=-v查看寄存器使用量和共享内存使用量。这两个数字直接决定了你的kernel能跑多快。
1.6 小结
GPU的内存层次结构,本质上是在解决冯·诺依曼瓶颈。通过多层存储结构,让数据尽量靠近计算单元。同时,用大量线程的快速切换来隐藏内存延迟。
理解这个全景图,是写出高性能CUDA代码的第一步。后面的章节,我会深入每一层的细节,包括如何手动管理共享内存、如何避免bank conflict、如何利用寄存器文件等等。
公众号:蓝海资料掘金营,微信deep3321