2、CUDA内存模型速览:全局内存、共享内存、寄存器、局部内存、常量内存、纹理内存

好,咱们直接切入正题。显存碎片治理,你得先知道显存里到底有哪些“坑位”。CUDA的内存模型,说白了就是GPU上不同层级的“储物间”。每个储物间的访问速度、容量、生命周期都不一样。

我个人习惯把CUDA内存模型比作一个团队协作的厨房。你想想看,大厨(GPU核心)干活,总得有个顺手的地方放食材和工具吧?

2.1 全局内存(Global Memory)—— 大仓库,但慢

全局内存是GPU里最大的那块显存。你在代码里用 cudaMalloc 分配出来的,基本都是它。CPU和GPU都能访问,是整个程序的“公共区域”。

核心特点:

  • 容量最大(几个GB到几十个GB)
  • 延迟最高(几百个时钟周期)
  • 所有线程都能读写
  • 生命周期由程序员控制(你分配,你释放)

我在项目中遇到过一个问题:有个同事把频繁使用的中间结果也放全局内存,结果性能直接腰斩。嗯,这里要注意,全局内存虽然大,但千万别把它当“高速缓存”用。

避坑指南:我曾经在调试一个图像处理程序时,发现全局内存的访问模式没对齐,导致带宽利用率只有理论值的30%。后来改成连续访问,性能立马就上来了。记住,全局内存喜欢“整齐划一”的访问。

2.2 共享内存(Shared Memory)—— 厨房操作台

共享内存是每个线程块(Block)私有的“小厨房”。同一个块里的线程都能访问它。速度比全局内存快得多(延迟低一个数量级),但容量很小(通常几十KB)。

说白了,共享内存就是让你在块内做数据“热交换”用的。比如做矩阵乘法时,把子矩阵先搬到共享内存里,再让线程们去算,能省下大量访问全局内存的时间。

我的经验:我建议你在设计kernel时,优先考虑能不能用共享内存做数据复用。哪怕只复用一次,收益也很大。但要注意,共享内存用多了,每个SM上能同时跑的线程块数量就会减少,这叫“占用率”下降,你得权衡。

2.3 寄存器(Register)—— 手里的工具

寄存器是GPU里最快的内存,没有之一。每个线程私有的,编译器会把你的局部变量尽量放到寄存器里。但寄存器数量有限(每个线程最多255个,但实际通常更少)。

为什么会这样?因为GPU要同时跑成千上万个线程,每个线程分到的寄存器就那么几个。如果你一个线程用了太多寄存器,编译器会把多余的数据“溢出”到局部内存,速度就慢了。

关键点:写kernel时,尽量少定义大的局部数组。我见过有人直接在kernel里声明一个 float temp[1024],结果寄存器不够用,全溢出到局部内存,性能惨不忍睹。

2.4 局部内存(Local Memory)—— 寄存器的“备胎”

局部内存听起来像“局部变量”,其实它物理上就在全局内存里。只是每个线程私有的,别的线程看不到。当寄存器不够用时,编译器会自动把变量“挤”到局部内存里。

嗯,这里要注意:局部内存的延迟和全局内存一样高。所以如果你发现kernel性能不对劲,可以检查一下是不是寄存器溢出太多了。用 --ptxas-options=-v 编译选项就能看到寄存器使用情况。

避坑指南:我曾经优化一个kernel,怎么调都跑不快。后来用 nvcc --resource-usage 一看,每个线程用了80多个寄存器,溢出严重。后来我手动把一些变量改成共享内存里的临时变量,寄存器降到32个,性能直接翻倍。

2.5 常量内存(Constant Memory)—— 只读的“菜谱”

常量内存是全局内存里一块特殊的只读区域,容量很小(64KB)。但它有专门的缓存,当所有线程都访问同一个地址时,速度非常快。

我建议你把那些不会变的参数(比如滤波器系数、查找表)放到常量内存里。注意,如果每个线程访问的地址都不一样,常量内存反而会变慢,因为缓存会失效。

使用场景:比如做图像卷积时,卷积核的权重是固定的,所有像素点都用同一套权重。这时候用常量内存就非常合适。我在项目中用常量内存存过FFT的旋转因子表,效果不错。

2.6 纹理内存(Texture Memory)—— 有“特殊技能”的只读内存

纹理内存也是只读的,但它有硬件支持的“插值”和“边界处理”功能。适合做图像处理、科学计算中的非对齐访问。

说白了,如果你要访问的数据有空间局部性(比如二维图像里的相邻像素),纹理内存能帮你自动做缓存优化。而且它支持一维、二维、三维的访问模式。

我个人习惯在需要做双线性插值或者处理边界溢出时,才用纹理内存。平时用常量内存或者全局内存的只读缓存就够用了。

2.7 一张图看懂CUDA内存模型

下面这张SVG图,把上面说的几种内存的关系和层级画清楚了。你一看就明白。

CUDA内存模型层级图 全局内存(Global Memory) 容量大(GB级),延迟高(~400周期),所有线程可读写 常量内存(Constant Memory) 64KB,只读,有缓存 适合所有线程访问同一地址 纹理内存(Texture Memory) 只读,支持插值和边界处理 适合空间局部性访问 共享内存(Shared Memory) 每个Block私有,容量小(几十KB),延迟低(~30周期) 块内线程可共享,适合数据复用 寄存器(Register) 每个线程私有,最快,数量有限 局部内存 寄存器溢出时使用 注:箭头表示数据流向或依赖关系。虚线表示“溢出”路径。

2.8 各内存对比一览

内存类型 作用域 生命周期 速度 容量 典型用途
全局内存 所有线程 + CPU 程序控制 慢(~400周期) GB级 大块数据存储
共享内存 单个Block Block生命周期 快(~30周期) 几十KB 块内数据复用
寄存器 单个线程 线程生命周期 最快(1周期) 每个线程几十个 局部变量、临时计算
局部内存 单个线程 线程生命周期 慢(同全局) 受限于显存 寄存器溢出时的备胎
常量内存 所有线程(只读) 程序控制 快(有缓存) 64KB 只读参数、查找表
纹理内存 所有线程(只读) 程序控制 快(有缓存) 受限于显存 图像、空间局部性访问

总结一下我的经验:写CUDA程序,其实就是在这几种内存之间做“数据搬运”的艺术。全局内存是底仓,共享内存是工作台,寄存器是手上的工具。常量内存和纹理内存是特殊场景的利器。你只要记住:数据在哪层用,就尽量放在哪层。别把频繁用的数据扔在全局内存里,也别把大数组塞进寄存器。

好了,这一章的内容就到这里。下一章我们会深入聊聊全局内存的分配与释放,以及如何避免那些常见的“坑”。