第三章 存储层次结构:寄存器文件、共享内存、L1/L2缓存、全局内存与HBM
各位同学,今天我们来聊聊GPU里最绕不开的话题——存储层次结构。
说实话,我见过太多做AI加速的工程师,算法调得飞起,结果一上板卡,性能直接腰斩。为什么?八成是内存访问模式出了问题。你想想看,计算单元再快,数据喂不进去,那就是白搭。
3.1 寄存器文件:最快但最稀缺
寄存器文件,说白了就是离计算单元最近的家当。在GPU里,每个线程都有自己的私有寄存器。我习惯把它比作「桌面上的工具」——伸手就能拿到,但桌面就这么大。
关键参数:
- 访问延迟:1个时钟周期
- 容量:每个线程最多255个32位寄存器(NVIDIA架构)
- 作用域:线程私有
实战经验:我在做某个卷积算子优化时,发现寄存器溢出(register spilling)导致性能掉了40%。当时我死活想不通,明明算法没问题啊。后来一查,是编译器把局部变量挤到全局内存里去了。嗯,这里要注意——寄存器不够用的时候,编译器会悄悄把数据挪到L1缓存甚至全局内存,你根本不知道。
避坑指南:我曾经在写矩阵乘法时,为了省寄存器,把中间结果反复从全局内存读取。结果呢?比直接用寄存器慢了8倍。所以我的建议是:宁可少开几个线程,也要保证每个线程有足够的寄存器。
3.2 共享内存:可编程的L1缓存
共享内存是个好东西。它位于芯片上,由程序员显式控制。说白了,就是让你手动管理数据复用。
为什么需要它?因为全局内存太慢了。你想想看,一个线程从全局内存读数据要几百个周期,而共享内存只要几十个周期。如果能让一个线程块里的所有线程共享同一份数据,那性能提升是立竿见影的。
典型用法:
// 声明共享内存
__shared__ float tile[BLOCK_SIZE][BLOCK_SIZE];
// 从全局内存加载到共享内存
tile[threadIdx.y][threadIdx.x] = global[base + threadIdx.y * WIDTH + threadIdx.x];
// 同步,确保所有线程都加载完毕
__syncthreads();
// 从共享内存读取,进行矩阵乘法计算
float sum = 0.0f;
for (int k = 0; k < BLOCK_SIZE; ++k) {
sum += tile[threadIdx.y][k] * tile[k][threadIdx.x];
}
注意:共享内存有bank冲突。我在项目中遇到过,明明用了共享内存,性能却没提升多少。一分析,发现是bank冲突导致串行化访问。解决办法?要么padding,要么重新组织数据布局。
3.3 L1/L2缓存:硬件自动管理
L1和L2缓存是硬件自动管理的,程序员没法直接控制。但你可以通过数据访问模式来「暗示」缓存。
L1缓存:
- 每个SM独立拥有
- 容量:通常16KB-128KB
- 延迟:约30个时钟周期
- 特点:对局部性敏感
L2缓存:
- 所有SM共享
- 容量:通常几MB
- 延迟:约200个时钟周期
- 特点:缓解全局内存带宽压力
我个人习惯把L1缓存当作「意外之喜」。什么意思呢?就是不要依赖它,但如果数据访问模式好,它能帮你白捡性能。比如,连续访问内存地址,L1缓存命中率就高。反之,随机访问?那L1基本等于摆设。
3.4 全局内存:大容量但慢
全局内存是GPU里最大的存储空间,通常有几十GB。但它的延迟也是最高的——几百个时钟周期。
访问模式决定性能:
| 访问模式 | 带宽利用率 | 说明 |
|---|---|---|
| 合并访问(coalesced) | 接近100% | 相邻线程访问相邻地址 |
| 非合并访问 | 10%-50% | 线程访问分散地址 |
| 随机访问 | <10% | 基本等于没优化 |
实战案例:我记得有一次优化一个图像处理算子,原始代码是这么写的:
// 糟糕的访问模式
for (int i = 0; i < N; ++i) {
output[threadIdx.x] += input[threadIdx.x * N + i];
}
改成合并访问后:
// 好的访问模式
for (int i = 0; i < N; ++i) {
output[threadIdx.x] += input[i * N + threadIdx.x];
}
性能直接提升了5倍。你想想看,就改了一行代码,效果天差地别。
3.5 HBM:高带宽内存
HBM(High Bandwidth Memory)是近年来GPU的标配。它通过3D堆叠技术,把多个DRAM die堆在一起,中间用硅通孔(TSV)连接。
HBM的优势:
- 带宽极高:HBM2e可达1.6TB/s,HBM3超过3TB/s
- 功耗低:相比GDDR6,能效比提升约3倍
- 容量适中:单颗HBM2e通常8GB-16GB
说白了,HBM就是给GPU修了一条超宽的高速公路。但要注意,带宽再高,也架不住你乱访问。我见过有人用HBM跑随机访问,结果带宽利用率不到5%。
我的建议:用HBM时,一定要做带宽测试。我曾经在项目里用了一个第三方库,结果它内部做了大量小粒度随机访问,把HBM的带宽全浪费了。后来我们自己重写了数据加载部分,带宽利用率从15%提升到了85%。
3.6 存储层次总结
好了,我们来捋一捋整个存储层次:
| 存储层次 | 容量 | 延迟 | 管理方式 | 作用域 |
|---|---|---|---|---|
| 寄存器文件 | 几十KB/SM | 1周期 | 编译器 | 线程私有 |
| 共享内存 | 几十KB/SM | ~30周期 | 程序员 | 线程块内 |
| L1缓存 | 16-128KB/SM | ~30周期 | 硬件 | SM内 |
| L2缓存 | 几MB | ~200周期 | 硬件 | 全局 |
| 全局内存 | 几十GB | ~400周期 | 程序员 | 全局 |
| HBM | 8-80GB | ~400周期 | 硬件+程序员 | 全局 |
核心原则:数据越热,越要放在靠近计算单元的地方。说白了,就是「热数据上移,冷数据下移」。我见过太多人把热数据放在全局内存里反复读,结果性能惨不忍睹。
最后说一句,存储层次结构不是死的。不同的GPU架构,参数会有差异。但核心思想不变——理解数据流动,优化访问模式。你把这个搞明白了,AI加速就成功了一半。
下一章,我们来聊聊指令流水线和Warp调度。嗯,那又是另一个有意思的话题了。