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图,把上面说的几种内存的关系和层级画清楚了。你一看就明白。
2.8 各内存对比一览
| 内存类型 | 作用域 | 生命周期 | 速度 | 容量 | 典型用途 |
|---|---|---|---|---|---|
| 全局内存 | 所有线程 + CPU | 程序控制 | 慢(~400周期) | GB级 | 大块数据存储 |
| 共享内存 | 单个Block | Block生命周期 | 快(~30周期) | 几十KB | 块内数据复用 |
| 寄存器 | 单个线程 | 线程生命周期 | 最快(1周期) | 每个线程几十个 | 局部变量、临时计算 |
| 局部内存 | 单个线程 | 线程生命周期 | 慢(同全局) | 受限于显存 | 寄存器溢出时的备胎 |
| 常量内存 | 所有线程(只读) | 程序控制 | 快(有缓存) | 64KB | 只读参数、查找表 |
| 纹理内存 | 所有线程(只读) | 程序控制 | 快(有缓存) | 受限于显存 | 图像、空间局部性访问 |
总结一下我的经验:写CUDA程序,其实就是在这几种内存之间做“数据搬运”的艺术。全局内存是底仓,共享内存是工作台,寄存器是手上的工具。常量内存和纹理内存是特殊场景的利器。你只要记住:数据在哪层用,就尽量放在哪层。别把频繁用的数据扔在全局内存里,也别把大数组塞进寄存器。
好了,这一章的内容就到这里。下一章我们会深入聊聊全局内存的分配与释放,以及如何避免那些常见的“坑”。