第二讲:内存层次结构全景——全局内存、共享内存、寄存器、本地内存、常量内存、纹理内存的特性与带宽对比

各位同学,今天我们来聊聊CUDA的内存体系。说实话,我刚接触CUDA那会儿,最头疼的就是这堆内存类型。全局内存、共享内存、寄存器、本地内存、常量内存、纹理内存……光名字就够绕的。但你别急,搞懂了它们,你的算子性能至少能翻个倍。

我习惯把GPU的内存体系比作一个「仓库+工作台」的系统。你想想看,仓库(全局内存)很大,但取东西慢;工作台(寄存器)极小,但随手就能拿到。咱们写CUDA程序,说白了就是在不同层级间倒腾数据,尽量减少去仓库的次数。

2.1 全局内存:大而慢的仓库

全局内存是GPU里最大的存储空间,通常有几十GB。所有线程都能访问它,但代价是延迟高——大概400-800个时钟周期。我在项目中遇到过,有人把频繁访问的变量全放在全局内存里,结果性能惨不忍睹。

关键特性:

  • 容量最大(GB级别)
  • 延迟最高(400-800 cycles)
  • 所有线程可读写
  • 支持缓存(L1/L2)

这里有个坑:全局内存的访问必须满足合并访问原则。什么意思?就是相邻线程要访问相邻地址。我曾经有个项目,因为没注意内存对齐,带宽利用率直接掉到10%。嗯,后来我花了整整两天排查这个问题。

2.2 共享内存:小而快的手工缓存

共享内存是我个人最喜欢的存储类型。它在每个SM(流多处理器)内部,容量只有几十KB,但延迟低到20-30个时钟周期。关键是——你可以手动控制它。

说白了,共享内存就是程序员自己管理的缓存。你在全局内存里取数据,放到共享内存里,然后所有线程块内的线程都能快速访问。我建议你在做矩阵乘法、卷积这类算子时,优先考虑用共享内存做数据复用。

避坑指南:我曾经在共享内存上踩过一个大坑——bank conflict。共享内存被分成32个bank,如果多个线程同时访问同一个bank,就会串行化。解决办法?要么填充数据,要么调整访问模式。

2.3 寄存器:最快但最稀缺

寄存器是GPU里最快的存储,延迟几乎为0。但每个线程能用的寄存器数量有限——通常是255个(32位寄存器)。如果你在核函数里声明太多局部变量,编译器会把多余的变量「溢出」到本地内存,性能直接跳水。

我记得有一次优化一个算子,发现性能上不去。一查,每个线程用了300多个寄存器,编译器默默地把多余的变量放到了本地内存。本地内存虽然物理上在全局内存里,但访问速度慢得多。后来我减少了中间变量的使用,性能提升了30%。

2.4 本地内存:寄存器的「替补」

本地内存听起来像「本地」的,其实它物理上就在全局内存里。只是每个线程有自己的私有空间。当寄存器不够用时,编译器会自动把变量放到本地内存。你想想看,这速度能快吗?

怎么避免?我建议你在写核函数时,尽量复用变量,少声明临时数组。如果实在需要大数组,考虑用共享内存或者全局内存。

2.5 常量内存:只读且带缓存

常量内存是全局内存里的一块特殊区域,容量只有64KB。它最大的特点是带有一个专用的常量缓存,当所有线程访问同一个地址时,速度极快。

我一般在存放模型参数、查找表这类数据时用常量内存。注意,它只读,而且所有线程必须访问同一个地址才能发挥缓存优势。如果每个线程访问不同地址,那还不如用全局内存。

2.6 纹理内存:为空间局部性而生

纹理内存最初是为图形处理设计的,但用在通用计算里也有奇效。它支持硬件插值、边界处理,而且对二维/三维空间局部性访问特别友好。

我个人习惯在做图像处理、流体模拟这类需要随机访问二维数据的场景时,用纹理内存代替全局内存。它的缓存机制和全局内存不同,更适合非对齐访问。

2.7 带宽对比:一张表说清楚

内存类型 容量 延迟(cycles) 带宽(理论) 作用域
寄存器 ~256个/线程 0-1 极高 单个线程
共享内存 ~48KB/SM 20-30 ~1.5TB/s 线程块内
本地内存 取决于全局内存 400-800 同全局内存 单个线程
全局内存 GB级别 400-800 ~900GB/s 所有线程
常量内存 64KB ~5(缓存命中) 取决于缓存 所有线程
纹理内存 取决于全局内存 100-300 取决于缓存 所有线程

注意:上面的带宽数据是理论峰值,实际能达到60%-80%就算不错了。我曾经见过有人号称「跑满了带宽」,结果一测只有30%。嗯,多半是没做合并访问。

2.8 知识体系结构图

下面这张图帮你理清各内存类型的关系和访问路径:

CUDA内存层次结构 全局内存(Global Memory) 容量:GB级别 | 延迟:400-800 cycles | 带宽:~900GB/s 常量内存 64KB | 只读 带常量缓存 纹理内存 空间局部性优化 支持硬件插值 共享内存(Shared Memory) ~48KB/SM | 延迟:20-30 cycles | 线程块内共享 寄存器(Register) ~256个/线程 | 延迟:0-1 cycle 本地内存(溢出) 手动拷贝 自动加载 寄存器溢出

从这张图你能看到,数据从全局内存出发,经过手动或自动的方式,流向共享内存、常量内存、纹理内存,最终到达寄存器。每一层都在缩小容量、降低延迟。你想想看,优化的本质就是让数据尽量「待」在靠近计算单元的地方。

2.9 实战建议

说了这么多,我总结几条实战经验:

  • 优先用寄存器:能放寄存器的变量别放别处。比如循环计数器、临时计算结果。
  • 共享内存做数据复用:如果多个线程需要同一份数据,果断用共享内存。我做过一个矩阵乘法,用共享内存后性能提升了5倍。
  • 常量内存存参数:所有线程都读同一个地址时,常量内存的缓存效率极高。
  • 纹理内存处理不规则访问:如果你的访问模式是随机的,纹理内存比全局内存友好得多。
  • 避免本地内存:检查编译报告,看看有没有变量被溢出到本地内存。有的话,要么减少变量,要么用共享内存替代。

一个小技巧:用 nvcc --ptxas-options=-v 编译你的代码,它会告诉你每个核函数用了多少寄存器、共享内存。我每次优化前都会跑一遍这个命令,心里就有底了。

好了,这一讲的内容就到这里。记住,内存管理是CUDA优化的核心。你把这些内存类型玩明白了,写出来的算子性能不会差。

想要更多资料,联系可可