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的内存层次理清楚。

GPU内存层次结构全景图 全局内存 (Global Memory) 容量大(GB级)· 延迟高(~400-800周期)· 所有线程可访问 L2缓存 (L2 Cache) 所有SM共享 · 延迟~200周期 流多处理器 (Streaming Multiprocessor, SM) 共享内存 Shared Memory ~几十KB · 延迟~30周期 L1缓存 L1 Cache ~128KB · 延迟~30周期 寄存器文件 Register File ~256KB · 延迟~1周期 计算核心 (CUDA Cores / Tensor Cores) 每个SM包含几十到几百个核心 本地内存 (Local Memory) 寄存器溢出时使用 · 实际在全局内存中 图例 全局内存 L2缓存 共享内存 L1缓存 寄存器

这张图我画了好几次才满意。你看,从顶层的全局内存到底层的寄存器,每一层都在用空间换速度。越靠近计算单元,容量越小但速度越快。

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