GPU 读写内存实际发生了什么
GPU 读写内存实际发生了什么
本文基于 Doubleword 的两篇文章, 重点整理 GPU 读取数据, 写入数据, Cache 与显存的关系, 以及 GPU 与 CPU 之间的数据传输.
文章中的具体实验对象是 NVIDIA RTX 4090 + CUDA, 因此具体延迟和缓存策略不能直接套用到其他 GPU, 但整体内存层级的理解具有参考价值.
W - What: 结论
1. Shader 访问 Buffer, 不等于直接访问显存
从程序角度看:
1
2
float x = Buffer[id]; // 读取
Buffer[id] = value; // 写入
看起来都是直接操作 GPU Buffer.
从硬件角度看, GPU Buffer 背后存在多级存储:
1
2
3
4
5
6
7
Shader
↓
L1 Cache
↓
L2 Cache
↓
显存 DRAM
其中:
- L1 Cache: 离计算单元较近, 小而快.
- L2 Cache: 更大, 是整个 GPU 内存系统的重要共享层.
- 显存 DRAM: RTX 4090 上即 GDDR6X 芯片, 容量大, 但访问代价最高.
因此, Shader 读取或写入 Buffer 时, 数据不一定真的访问了物理显存.
2. GPU 读取数据时, 是一个”请求出去, 数据回来”的过程
例如:
1
float x = Buffer[id];
完整过程可以简化为:
1
2
3
4
5
6
7
8
读取请求:
Shader → L1 → L2 → 显存
读取结果:
Shader ← L1 ← L2 ← 显存
实际读取时, 会逐级查找:
1
2
3
4
5
6
7
8
9
先查 L1
↓
没有
再查 L2
↓
没有
最后访问显存
如果 L1 或 L2 已经有数据, 就不需要继续访问显存.
如果最终访问显存, 数据读出后会经过 L2, L1, 最后回到 Shader 使用的寄存器.
因此, 读取的关键特点是:
Shader 后续计算如果依赖这个数据, 就必须等数据回来.
3. GPU 写入数据时, 通常不需要等数据真正进入显存
例如:
1
Buffer[id] = value;
可以简化为:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Shader 算出结果
↓
提交写入
↓
L1
↓
L2
↓
Shader 可以继续执行
与此同时:
L2 中的数据
↓
在之后合适的时机
↓
写回显存
也就是说:
“Shader 已经执行完写入”, “其他 GPU 工作已经能看到新数据”, “数据已经真正进入显存芯片”, 是三个不同的时刻.
这也是读和写最重要的区别:
1
2
3
4
5
Read:
请求出去 + 数据必须回来.
Write:
数据交出去, Shader 通常不必等待它最终进入显存.
4. 最新数据可以只存在 L2 中, 显存仍然是旧值
例如原始状态:
1
2
显存:
A = 5
Shader 写入:
1
A = 10
之后完全可能出现:
1
2
3
4
5
L2 Cache:
A = 10 ← 最新值
显存:
A = 5 ← 旧值
此时 GPU 内存系统仍然可以正确工作.
如果其他 GPU 工作读取 A, 可以从 L2 得到 10, 并不要求先把 10 写回显存.
这种”Cache 中的数据已经被修改, 但显存中的版本还是旧的”状态, 称为 Dirty, 即脏数据.
5. 脏数据之后才会写回显存
当 L2 中的脏数据需要被清理时:
1
2
3
4
5
6
7
L2:
A = 10
↓ Write-back, 回写
显存:
A = 10
RTX 4090 并不是简单地等 Cache 完全没有空间以后才开始回写.
作者实验发现, 一个 L2 Cache 组有 16 个位置. 当其中脏数据达到至少 8 个时, GPU 会开始主动清理其中较久没有被写过的数据.
可以理解为:
1
2
3
4
5
6
7
8
9
16 个缓存位置
↓
脏数据越来越多
↓
Dirty >= 8
↓
GPU 主动选择部分脏数据
↓
提前写回显存
这样可以避免等到 Cache 必须腾位置时, 突然产生大量显存写入.
6. CPU 获取 GPU 结果时, 数据甚至可以不先进入显存
这是两篇文章中一个很值得注意的结果.
通常容易想象为:
1
2
3
4
5
6
7
8
9
10
11
Shader
↓
L1
↓
L2
↓
显存
↓
PCIe
↓
CPU 内存
但 RTX 4090 的实验中, 某些刚计算出的结果仍然只以最新版本存在于 L2 中时, cudaMemcpyDeviceToHost 就可以直接把结果取走:
1
2
3
4
5
6
7
8
9
Shader
↓
L1
↓
L2
↓
PCIe
↓
CPU 内存
因此:
CPU 要的是”这个 GPU 内存地址对应的最新数据”, 而不是要求”这份数据必须已经物理写入 GDDR6X”.
但这不意味着 CPU 可以直接访问 GPU 的 L2 Cache.
更准确的说法是:
GPU 的内存/传输系统可以从 L2 取得最新数据, 然后通过 PCIe 传输到 CPU 内存.
I - Insight: 这对图形开发意味着什么
1. “访问 GPU Buffer”和”访问显存”不是同一个概念
例如:
1
float value = Buffer[index];
这次读取可能来自:
1
2
3
4
5
L1
或
L2
或
显存
因此, Shader 代码中读取了多少数据, 并不等价于真正产生了多少显存流量.
真正的显存压力还取决于 Cache 是否能够命中.
2. 数据访问方式会直接影响性能
GPU 会同时执行很多线程.
在 NVIDIA GPU 上, 通常将 32 个线程组成一个执行组, 称为 Warp.
假设这 32 个线程分别写:
1
2
3
4
5
Thread 0 → Buffer[0]
Thread 1 → Buffer[1]
Thread 2 → Buffer[2]
...
Thread 31 → Buffer[31]
这些地址连续, GPU 可以把很多线程产生的访问合并成较少的实际内存请求.
而如果访问变成:
1
2
3
4
Thread 0 → Buffer[9281]
Thread 1 → Buffer[17]
Thread 2 → Buffer[40002]
...
访问难以合并, Cache 的利用也通常更差.
因此, 对 Compute Shader, GraphicsBuffer, RWStructuredBuffer, GPU Culling, GPU Mesh Generation 等场景来说:
数据布局和访问顺序本身就是性能设计的一部分.
3. Cache 的意义不是单纯”加速读取”
Cache 同时承担:
1
2
3
4
5
读取:
尽量避免访问显存.
写入:
暂时保存最新结果, 延后真正的显存写入.
因此可以把 GPU 内存系统理解为:
1
2
3
4
5
┌─ 读取时尽量在这里找到数据
Shader ↔ L1 ↔ L2 ──┤
└─ 写入时也可以先把最新数据留在这里
↕
显存
Cache 不只是显存前面的一层”读取加速器”, 它也是整个内存一致性和数据流动体系的一部分.
4. GPU 为什么需要大量并行线程
访问显存的延迟很高.
RTX 4090 上作者测得:
1
2
3
L1 命中: 约 15.4 ns
L2 命中: 约 127.4 ns
显存访问: 约 255.4 ns
如果某组线程正在等待显存:
1
2
3
线程组 A
↓
等待数据
GPU 不会让整个芯片停下来, 而是去执行其他准备好的线程组:
1
2
3
4
5
6
7
8
9
A 等待
↓
执行 B
↓
执行 C
↓
A 的数据回来
↓
继续执行 A
因此大量并行线程不仅用于”同时计算很多数据”, 也用于隐藏内存访问等待.
S - System: 建立完整心智模型
1. 读取流程
以:
1
float x = Buffer[id];
为例.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Shader
↓
产生读取请求
↓
L1 查找
├─ 找到 → 返回 Shader
└─ 没找到
↓
L2 查找
├─ 找到 → 返回 L1 → Shader
└─ 没找到
↓
显存读取
↓
L2
↓
L1
↓
Shader
用最短形式记忆:
1
2
3
4
5
请求:
Shader → L1 → L2 → 显存
结果:
Shader ← L1 ← L2 ← 显存
2. 写入流程
以:
1
Buffer[id] = value;
为例.
1
2
3
4
5
6
7
8
9
10
11
12
13
Shader 计算结果
↓
提交写入请求
↓
GPU 整理多个线程的内存访问
↓
L1
↓
L2
↓
数据成为最新版本
↓
Shader 已经可以继续工作
之后 L2 再自行处理:
1
2
3
4
5
6
7
L2 中的脏数据
↓
需要清理 / 主动清理
↓
Memory Controller
↓
显存
这里的 Memory Controller, 内存控制器, 是 GPU 中负责真正和 GDDR6X 显存芯片通信的硬件.
3. RTX 4090 的 L2 清理规则
作者通过实验推测出的策略可以简化为两部分.
缓存需要腾位置时
GPU 会优先选择”近期不太可能再使用”的数据.
如果选中的数据是 Clean, 即 Cache 与显存内容一致:
1
直接丢弃 Cache 副本
如果是 Dirty:
1
2
3
4
5
先启动写回显存
↓
不能立即丢失
↓
再寻找可以释放的位置
脏数据太多时
一个 L2 组有 16 个位置.
当:
1
Dirty >= 8
新的写入到来时, GPU 会主动选择一个较久没有被写过的脏数据进行清理.
目的不是单纯腾空间, 而是让显存写入更加平滑:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
没有主动清理:
平时几乎不写
↓
Cache 被脏数据塞满
↓
突然大量写回显存
主动清理:
脏数据积累到一定程度
↓
陆续写回
↓
降低突发写入压力
4. CPU 与 GPU 的数据传输
独立显卡和 CPU 通常通过 PCIe 通信.
但”通过 PCIe 通信”并不意味着数据必须先进入物理显存.
例如 GPU 刚算出的结果:
1
2
3
4
5
L2:
Result = 最新值
显存:
Result = 旧值
如果 CPU 此时需要读取 Result:
1
2
3
4
5
6
7
L2
↓
GPU 数据传输系统
↓
PCIe
↓
CPU 内存
即可完成传输.
因此需要区分:
1
GPU Buffer 的最新数据在哪里
和:
1
这个数据是否已经写入物理显存
这不是同一个问题.
E - Evidence: 两篇文章实际验证了什么
两篇文章都以 RTX 4090 为实验对象, 通过 CUDA 指令, 性能计数器和针对缓存结构设计的实验, 反推出实际硬件行为.
主要实验结果包括:
| 项目 | RTX 4090 实验结果 |
|---|---|
| NVIDIA 一个 Warp | 32 个线程 |
| L1 读取命中 | 约 15.4 ns |
| L2 读取命中 | 约 127.4 ns |
| 显存完整读取往返 | 约 255.4 ns |
| 连续 32 × 4 Byte 写入 | 可整理成 4 个 32 Byte 数据块 |
| Store 从执行单元发出 | 约 6.1 cycles |
| Store 到达 L2 并获得确认 | 约 140 ns |
| L2 Cache 组 | 16 个位置 |
| 主动清理阈值 | 至少 8 个 Dirty 数据 |
| GPU → CPU | 实验中可直接从 L2 经 PCIe 传到 CPU 内存 |
这些数字属于 RTX 4090 的具体微架构结果.
不能直接认为 Adreno, Mali, PowerVR 或其他 NVIDIA GPU 都完全相同.
更值得保留的是以下通用心智模型:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
GPU Read:
请求出去
↓
逐级查 Cache
↓
必要时访问显存
↓
数据返回
↓
Shader 才能继续依赖该数据的计算
GPU Write:
提交数据
↓
数据进入 Cache / 内存系统
↓
Shader 通常可以继续
↓
之后再根据需要写回显存
以及:
1
2
3
4
5
Shader 已经执行写入
≠
所有位置已经看到新数据
≠
数据已经物理进入显存
术语速查
| 术语 | 本文中的含义 |
|---|---|
| Thread | GPU 上执行 Shader 的单个线程 |
| Warp | NVIDIA 将 32 个线程组成的执行组 |
| Load | 从内存读取数据 |
| Store | 向内存写入数据 |
| Cache | GPU 芯片内部的小容量高速缓存 |
| L1 Cache | 离计算单元较近的一级缓存 |
| L2 Cache | 更大的共享二级缓存, GPU 内存系统的重要汇合层 |
| Dirty | Cache 中是最新值, 但显存中仍是旧值 |
| Write-back | 将 Dirty 数据从 Cache 写回显存 |
| Memory Controller | GPU 中负责和显存芯片通信的硬件 |
| PCIe | 独立 GPU 与 CPU 之间常用的数据通信总线 |
| DRAM | 实际保存大量数据的动态随机存取存储器, 本文对应 RTX 4090 的 GDDR6X 显存 |
文献
Fergus Finn, Doubleword, What happens when a GPU reads memory
https://blog.doubleword.ai/what-happens-when-a-gpu-reads-memoryFergus Finn, Doubleword, What happens when a GPU writes memory
https://blog.doubleword.ai/what-happens-when-a-gpu-writes-memory