1. CUDA内存模型全景:从全局内存到寄存器,一张图看懂GPU存储层次
大家好,我是你们的老朋友。今天咱们来聊聊CUDA编程里最基础、也最关键的一块——内存模型。
说实话,我刚开始学CUDA的时候,最头疼的就是这堆内存类型。全局内存、共享内存、寄存器、本地内存、常量内存、纹理内存……光名字就够绕的。更别提它们各自的速度、容量、作用域都不一样。你想想看,写个核函数,数据放哪儿、怎么放,直接决定了你的程序是跑得飞起还是卡成PPT。
所以这一章,我打算用一张图,把整个GPU存储层次给你讲透。嗯,咱们先看这张SVG图,它基本概括了今天要聊的所有内容。
1.1 为什么GPU需要这么多层内存?
说白了,就是速度与容量的博弈。寄存器最快,但一个SM里总共才256KB左右。全局内存容量大,但访问一次要等400多个时钟周期。你想想看,如果所有数据都放全局内存,那GPU核心大部分时间都在干等——这叫访存瓶颈。
我在项目中遇到过这样一个场景:一个矩阵乘法的核函数,最初直接读写全局内存,跑一次要12毫秒。后来我把频繁用到的子矩阵搬到了共享内存,时间直接降到了1.8毫秒。嗯,差了将近7倍。这就是内存层次结构的意义——让数据尽量靠近计算单元。
1.2 各层内存的详细解读
1.2.1 全局内存 (Global Memory)
这是GPU里最大的存储空间,所有线程都能访问。但代价就是慢——延迟400~800个时钟周期。我习惯把它比作「硬盘」,虽然大,但不能频繁读写。
- 容量:几GB到几十GB(取决于显卡)
- 作用域:所有线程 + 主机端
- 缓存:经过L2缓存,部分情况会经过L1
- 典型用途:存放输入数据、输出结果
我曾经犯过一个错误:在核函数里用全局内存做中间变量的累加。结果每个线程都在反复读写同一块全局地址,性能惨不忍睹。后来改成用共享内存做归约,速度提升了20倍。记住:全局内存不是用来做频繁读写的。
1.2.2 常量内存 (Constant Memory)
常量内存只有64KB,但有个好处——带缓存。如果同一个warp里的所有线程访问同一个常量地址,那速度会非常快,因为只需要一次内存读取,然后广播给所有线程。
- 容量:64KB(所有SM共享)
- 特点:只读,带缓存
- 延迟:缓存命中时约100 cycles
- 典型用途:系数表、查找表、固定参数
如果你有一组只读的查找表,而且所有线程访问的地址相同,那用
__constant__ 修饰就对了。我做过一个物理模拟,把原子间的力常数表放常量内存,比放全局内存快了3倍。
1.2.3 纹理内存 (Texture Memory)
纹理内存最初是为图形学设计的,但通用计算里也能用。它特别适合空间局部性强的访问模式——比如图像处理里的卷积操作,或者二维数组的邻域访问。
- 特点:只读,硬件插值,空间缓存优化
- 延迟:缓存命中时较低
- 典型用途:图像处理、科学计算中的网格数据
说实话,纹理内存在通用计算里用得不多。我个人习惯是:除非你明确需要硬件插值功能,否则直接用全局内存+手动缓存更灵活。
1.2.4 L2缓存 (L2 Cache)
L2缓存是所有SM共享的。它不归你直接控制,但它的存在对性能影响巨大。比如,如果两个SM访问同一块全局内存数据,L2缓存就能减少重复的全局内存访问。
- 容量:几MB(比如RTX 4090有72MB)
- 延迟:约200 cycles
- 特点:硬件自动管理,对所有全局内存访问透明
1.2.5 共享内存 (Shared Memory)
共享内存是CUDA编程里最常用的优化手段。它位于SM内部,延迟只有30个时钟周期左右。同一个Block里的所有线程都能访问它。
- 容量:每个SM 48KB~164KB(取决于架构和配置)
- 延迟:约30 cycles
- 作用域:同一个Block内的所有线程
- 典型用途:数据复用、线程间通信、归约操作
共享内存是GPU上「性价比」最高的存储层次。它比全局内存快10倍以上,而且你可以精确控制数据的生命周期。我几乎每个性能敏感的核函数都会用到它。
1.2.6 寄存器 (Registers)
寄存器是GPU里最快的存储,延迟只有1个时钟周期。每个线程都有自己的私有寄存器,编译器会自动分配。
- 容量:每个SM约256KB(比如每个线程最多255个寄存器)
- 延迟:1 cycle
- 特点:线程私有,编译器自动管理
这里有个坑:如果你在核函数里声明了太多局部变量,寄存器不够用,编译器会把多余的变量溢出到本地内存。本地内存其实就在全局内存里,延迟一下子飙到400 cycles。我曾经调试过一个核函数,发现性能比预期慢很多,一看PTX代码,好家伙,寄存器溢出严重。解决办法就是减少每个线程的变量数量,或者调整编译器的寄存器限制。
1.2.7 本地内存 (Local Memory)
本地内存是寄存器的「备胎」。当寄存器不够用时,编译器自动把局部变量放到本地内存。它虽然叫「本地」,但物理上就在全局内存里,所以延迟很高。
- 容量:受全局内存限制
- 延迟:400~800 cycles(和全局内存一样)
- 特点:编译器自动使用,开发者通常无感知
我曾经写过一个复杂的核函数,里面用了很多数组和结构体。编译后性能很差,用
--ptxas-options=-v 一看,每个线程用了80多个寄存器,大量溢出到本地内存。后来我把大数组拆成多个小变量,并减少了线程数,才把寄存器压力降下来。记住:寄存器溢出是性能杀手。
1.3 一张表总结所有内存类型
| 内存类型 | 容量 | 延迟 | 作用域 | 缓存 | 典型用途 |
|---|---|---|---|---|---|
| 寄存器 | 256KB/SM | 1 cycle | 线程私有 | 无 | 局部变量、中间结果 |
| 共享内存 | 48~164KB/SM | ~30 cycles | Block内 | 无 | 数据复用、线程通信 |
| L2缓存 | 几MB | ~200 cycles | 所有SM | 硬件管理 | 全局内存访问加速 |
| 常量内存 | 64KB | ~100 cycles | 所有线程 | 有 | 查找表、固定参数 |
| 纹理内存 | 受全局内存限制 | ~100 cycles | 所有线程 | 有 | 图像、空间局部性访问 |
| 全局内存 | GB级 | 400~800 cycles | 所有线程+主机 | L2/L1 | 输入输出数据 |
| 本地内存 | 受全局内存限制 | 400~800 cycles | 线程私有 | L2/L1 | 寄存器溢出 |
1.4 如何选择合适的内存层次?
这个问题没有标准答案,但我可以给你一个决策思路:
- 数据是否只读且所有线程访问相同地址? → 用常量内存
- 数据是否只读且访问模式有空间局部性? → 考虑纹理内存
- 数据是否被同一个Block内的多个线程复用? → 用共享内存
- 数据是否每个线程独有且生命周期短? → 用寄存器
- 其他情况 → 用全局内存,但注意合并访问
嗯,这个决策树我用了好几年,基本没出过问题。当然,实际项目中往往需要组合使用。比如我做过的一个流体模拟,全局内存存网格数据,共享内存做局部更新,寄存器存中间变量,常量内存存物理参数——各司其职。
1.5 本章小结
CUDA的内存模型,说白了就是一套分层存储体系。从最快的寄存器到最慢的全局内存,每一层都有它的定位和用途。你不需要一开始就记住所有细节,但一定要理解这张全景图——知道数据该放哪儿、为什么放那儿。
我个人习惯是:写核函数之前,先画一张数据流图,标清楚每个变量在哪个内存层次。这样写出来的代码,性能通常不会差。
好了,这一章就到这里。下一章咱们聊聊全局内存的合并访问——这可是CUDA性能优化的基本功,别错过。