1. CUDA显存体系全景
在CUDA性能优化中,绝大多数“程序跑不快”的问题,最终都会落到显存访问上。要判断一段kernel是算力受限还是带宽受限,第一步不是改代码,而是先建立一张清晰的显存层级地图:数据放在哪里、离SM有多远、访问一次要多少周期、能提供多大带宽。本章不涉及具体优化技巧,只做一件事——把这张地图画清楚。
一、为什么显存是分层的
GPU的算力增长远快于显存带宽增长,这个“剪刀差”是分层的根本原因。以典型的现代GPU为例,FP32算力可达数十TFLOPS,而HBM全局显存带宽通常在1~3TB/s量级。若每次运算都直接访问全局显存,SM会长期处于“等数据”状态。于是硬件在SM内部和附近布置了多级容量更小、但延迟更低、带宽更高的存储:寄存器、共享内存/L1、常量缓存、纹理缓存,以及容量大但慢的全局显存和本地内存。
理解分层的关键是三个维度:物理位置(在SM内、SM旁还是片外)、访问延迟(约多少个时钟周期)、有效带宽(单位时间能搬多少字节)。三者通常此消彼长:越靠近计算单元,越快越小越贵。
二、各层级存储的定位与差异
寄存器(Register)是速度最快的存储,位于SM的寄存器文件中,由编译器直接分配。访问延迟约1个周期,几乎不构成瓶颈。但每个线程可用寄存器数量有限(如每线程最多255个),寄存器用量过高会降低occupancy,甚至把变量“溢出”到本地内存。
共享内存(Shared Memory)位于SM内部,是片上可编程的高速暂存区,典型延迟约20~30周期。它按block分配,常用于线程间数据复用、矩阵分块、归约等。共享内存存在bank冲突问题:32个bank若被同一warp的多个线程同时访问同一bank的不同地址,访问会被串行化。
L1/L2缓存是硬件自动管理的缓存。L1与共享内存共享同一块物理SRAM(可配置划分),L2为全GPU共享,容量更大但延迟更高。它们对程序员透明,命中率取决于访问局部性。
常量内存(Constant Memory)位于片外,但有专用常量缓存。当同一warp内所有线程访问同一地址时,广播效率极高,接近寄存器速度;若线程访问不同地址,则退化为串行。适合存放kernel参数、系数表等只读且warp内一致的数据。
纹理内存(Texture Memory)同样位于片外,通过纹理缓存访问,针对二维空间局部性做了优化,并支持硬件插值、边界处理等。适合图像、网格等具有空间邻接关系的只读数据。
全局内存(Global Memory)是容量最大的显存,所有线程可见,延迟约400~800周期。它是带宽优化的主战场,访问是否合并(coalesced)直接决定有效带宽。
本地内存(Local Memory)名字有误导性:它并不在SM本地,而是全局显存的一部分,只是被编译器用来存放寄存器溢出的变量、动态索引数组等。因此本地内存访问同样慢,且容易造成隐蔽的性能损失。
三、延迟与带宽的数量级直觉
把上面的层级换算成“人走一步”的直觉:如果访问寄存器相当于从桌上拿笔(1秒),访问共享内存相当于走到隔壁房间(约半分钟),访问全局显存则相当于去另一个城市取件(约十几分钟)。这个比喻虽不精确,但足以说明:优化的本质是让数据尽量在近处被反复使用,而不是频繁往返远处。
带宽方面,全局显存带宽是硬上限。以一块假设带宽为1.5TB/s的GPU为例,若一个kernel需要读取4GB数据且只读一次,理论最短时间约为4GB / 1.5TB/s ≈ 2.7ms。若实测耗时远高于此,说明要么访问未合并导致有效带宽下降,要么存在大量重复读取。共享内存和寄存器的作用,正是把“重复读取”从全局显存转移到片上。
四、一张表看清各层级
| 存储类型 | 物理位置 | 典型延迟 | 可见范围 | 典型用途 |
|---|---|---|---|---|
| 寄存器 | SM 内 | ~1 周期 | 线程私有 | 标量运算、循环变量 |
| 共享内存 | SM 内 | ~20–30 周期 | block 内共享 | 分块复用、归约 |
| L1 / L2 缓存 | SM 内 / 全 GPU | 数十~百周期 | 硬件管理 | 自动缓存全局访问 |
| 常量内存 | 片外 + 常量缓存 | 命中时接近寄存器 | 全局只读 | kernel 参数、系数表 |
| 纹理内存 | 片外 + 纹理缓存 | 低于全局 | 全局只读 | 图像、空间邻接数据 |
| 全局内存 | 片外 HBM | ~400–800 周期 | 全局可读写 | 主数据存储 |
| 本地内存 | 片外(实为全局) | 同全局 | 线程私有 | 寄存器溢出、动态数组 |