1、GPU内存层次结构概览
各位同学,今天我们来聊聊GPU的内存子系统。说实话,这是整个GPU架构里最考验设计功底的部分。我做了十几年GPU设计,见过太多因为内存访问没处理好而导致性能崩盘的案例。你想想看,一个计算单元再快,如果数据喂不上去,那就是白搭。
GPU的内存层次,说白了就是一个「速度与容量」的博弈。越靠近计算单元的内存,速度越快,但容量越小;越远的内存,容量越大,但延迟也越高。这个道理大家都懂,但真正在硬件设计里怎么权衡,里面的门道可不少。
寄存器文件:最快但最稀缺
寄存器文件(Register File)是离计算单元最近的内存。我习惯把它比作「工程师桌上的笔记本」——随手就能写,但地方就那么点。
- 访问延迟:1个时钟周期,几乎是零延迟
- 容量:每个线程通常只有几十到几百个寄存器(比如NVIDIA的每个线程最多255个32位寄存器)
- 特点:每个线程独享,编译器分配
注意:我曾经在一个项目里,因为寄存器分配过多导致线程束(warp)占用率下降,性能反而变差了。寄存器不是越多越好,够用就行。寄存器溢出(spilling)到本地内存,那延迟可就惨了。
共享内存:可编程的L1缓存
共享内存(Shared Memory)是个好东西。它位于同一个线程块(thread block)内,所有线程都能访问。我个人习惯叫它「团队协作区」。
| 属性 | 典型值 |
|---|---|
| 延迟 | 约20-30个时钟周期 |
| 容量 | 每个SM通常48KB-96KB(可配置) |
| 带宽 | 极高,每个bank独立访问 |
这里有个坑要注意——bank冲突。共享内存被分成32个bank(对应32个线程),如果多个线程同时访问同一个bank的不同地址,就会发生冲突,访问会串行化。我建议你在设计时尽量让相邻线程访问相邻地址,这样能完美避开冲突。
小技巧:如果你不确定共享内存的bank数量,可以查一下架构手册。以Volta架构为例,共享内存有32个bank,每个bank宽度4字节。访问时记得加padding来避免冲突。
L1/L2缓存:自动管理的加速器
L1缓存和共享内存其实共享同一块物理存储。你可以把它们想象成「可伸缩的办公桌」——想多放点共享数据,就少留点缓存空间;反之亦然。
- L1缓存:每个SM独立,延迟约30-40周期,容量通常16-48KB
- L2缓存:所有SM共享,延迟约200-400周期,容量几MB到几十MB
嗯,这里要注意。L1缓存对全局内存的访问是透明的,你不需要手动管理。但共享内存需要你显式地加载和存储数据。我个人的经验是:如果数据会被反复使用,而且访问模式有规律,那就用共享内存;如果访问比较随机,交给L1缓存反而更省心。
全局内存:容量最大,延迟最高
全局内存(Global Memory)是GPU的主存,通常就是板载的GDDR显存。容量从几GB到几十GB不等,但延迟嘛……说实话,挺高的。
- 延迟:400-800个时钟周期(甚至更高)
- 带宽:很高(比如HBM2e可达1.6TB/s),但延迟是硬伤
- 特点:所有线程都能访问,但需要对齐和合并访问
为什么延迟这么高?因为全局内存离计算单元最远,中间要经过内存控制器、总线、甚至跨芯片通信。我记得有一次调试一个深度学习训练任务,发现性能瓶颈就在全局内存的随机访问上。后来改成连续访问,吞吐量直接翻了三倍。
核心要点:访问全局内存时,尽量让同一个线程束内的线程访问连续的地址。这叫「合并访问」(coalesced access),是GPU优化的基本功。不合并的话,带宽利用率可能只有10%甚至更低。
常量内存:只读但带广播
常量内存(Constant Memory)是个特例。它只有64KB,但有个神奇的特性——如果同一个线程束内的所有线程访问同一个地址,硬件会把这个值广播给所有线程,只产生一次内存访问。
- 延迟:约200-400周期(但广播时几乎无额外开销)
- 容量:64KB(对,就这么点)
- 适用场景:存储系数、查找表、模型参数等只读数据
我建议你把那些所有线程都会用到的常量放在这里。比如卷积核的权重、光线追踪的材质参数。但注意,如果每个线程访问的地址都不一样,那常量内存反而比全局内存还慢——因为硬件会串行化这些请求。
纹理内存:为空间局部性而生
纹理内存(Texture Memory)最初是为图形渲染设计的,但后来在通用计算里也大放异彩。它利用空间局部性,对二维或三维数据的随机访问做了优化。
- 延迟:与全局内存类似,但缓存命中时很快
- 特点:硬件自动处理边界、插值、归一化
- 适用场景:图像处理、体渲染、随机访问模式
说实话,纹理内存的硬件缓存策略和L1/L2不太一样。它更擅长处理「附近像素一起访问」的模式。如果你在做图像滤波或者光线追踪,纹理内存往往比全局内存更高效。不过要注意,纹理内存是只读的,别想着用它来写数据。
延迟对比一览
| 内存类型 | 典型延迟(时钟周期) | 容量 | 作用域 |
|---|---|---|---|
| 寄存器文件 | 1 | 几十~几百字节/线程 | 线程私有 |
| 共享内存 | 20-30 | 48-96KB/SM | 线程块内 |
| L1缓存 | 30-40 | 16-48KB/SM | SM私有 |
| L2缓存 | 200-400 | 几MB~几十MB | 全局共享 |
| 全局内存 | 400-800 | 几GB~几十GB | 全局 |
| 常量内存 | 200-400(广播时低) | 64KB | 全局只读 |
| 纹理内存 | 200-600(依赖缓存) | 取决于显存 | 全局只读 |
你看这个表格,从寄存器到全局内存,延迟差了三个数量级。这就是为什么我们做GPU优化时,第一件事就是「减少对全局内存的访问」。能放寄存器的放寄存器,能放共享内存的放共享内存,实在不行再考虑全局内存。
我的建议:刚开始接触GPU编程的同学,可以先从「合并访问」和「共享内存」这两个点入手。把这两个优化做好,性能通常能提升5-10倍。至于纹理内存和常量内存,等遇到具体场景再学也不迟。
好了,这一章我们大致了解了GPU内存的层级关系。下一章我会深入讲讲共享内存的bank冲突问题,以及怎么用padding来规避它。到时候我会拿一个我实际踩过的坑来举例,保证让你印象深刻。