NVIDIA GPU 上的内存结构从慢到快分为 Global Memory、L2 缓存、L1TEX
缓存 / Shared Memory 和寄存器,从 Volta/Turning 开始其中 L1TEX 缓存和
Shared Memory 在物理上放在了同一块芯片上,拥有相似的延时和带宽 1,因此如何掌握 Shared Memory
对性能而言就变得尤为重要。可惜的是,NVIDIA 官方的 CUDA
编程手册中介绍 Shared Memory 的教程其实只介绍了在每个线程访问一个 4
字节(32 位宽)的元素时 Bank Conflict
和广播机制,但对使用常用的向量化访存指令如 LDS.64 或
LDS.128 这种一次能访问 8 个字节(64 位宽)或 16 个字节(128
位宽)元素的情况却鲜有资料讨论。这篇文章大概就是想结合网络上的一些讨论以及通过
Microbenchmark 对这些细节来一探究竟。需要注意的是这篇文章的结论仅在
Turing 架构的 GPU 上验证过,其他架构的 GPU
可能会产生变化(欢迎评论区交流🤪)。
我们可以把 Shared Memory 它看作是一个长度为 N
的数组,每个数组元素的大小是 4 字节(比如可以是一个 int 或
float),这个数组对于同一个 Thread Block
中的所有线程都是可见的,但不同 Thread Block 之间的 Shared Memory
不能互相访问。其中 Shared Memory 最值得注意的点机制是其本身被划分称了 32
个 Bank,其中数组中第 i 个元素存储在了第 imod32 个 Bank 上。Shared
Memory 访存机制可以总结为如下两条:
- 如果一个 Warp 中的两个不同的线程同时访问了同一个元素,那么就会触发广播机制,即可以合并成对 Shared Memory 的一次访问;
- 如果一个 Warp 中两个不同的线程同时访问了同一个 Bank 中的不同元素,那么就会产生 Bank Conflict,可以理解成一个 Bank 同一时间只能产生吞吐一个元素,因此这两个线程的访存请求会被串行,因而会影响性能;
在 Nsight Compute 上,我们可以通过 Shared Memory 上的 Wavefront 数目来理解 Shared Memory 访存性能,Wavefront 越多说明访存需要的时间越长。
下面几张图片举了几个例子方便理解:
只会触发广播机制,没有 Bank Conflict,需要 1 个 Wavefront
不会触发广播机制,没有 Bank Conflict,需要 1 个 Wavefront
既会触发广播机制,也有 Bank Conflict,需要 4 个 Wavefront(注意第 18 个 Bank)
向量化访存指令
前面在讨论 Shared Memory 上的访存时,我们的 Shared Memory
模型只讨论了一个 Warp
内每个线程所访问的元素。在涉及到向量化访存时这样的模型就不起效果了,因为通过一个
LDS.64 或 LDS.128
指令就可以让一个线程一次性访问 8 个或 16 个字节(相当于 2 个或 4
个元素)。
正确的做法应该是就每个 Wrap 内所产生的每个 Memory
Transaction 而非每个 Warp 或每条指令来讨论(参考这里)。那么一个在
Shared Memory 上的向量化指令 LDS.64 或 LDS.128
指令到底对应多少个 Memory Transaction?我并没有找到 NVIDIA
给出的官方答案,通过一些网络上的讨论和我自己的
Microbenchmark,我对结合了向量化指令的 Shared Memory
的访存机制的推测如下。
首先一个原则是一个 Warp 中所有线程在同时执行一条 Shared Memory
访存指令时会对应到 1 个或多个 Memory Transaction,一个 Memory
Transaction 最长是 128 字节。如果一个 Warp
内在同一时刻所需要的访存超过了 128 字节,那么会则被拆成多个 Transaction
进行。因为一个 Warp
同一时刻执行的访存指令的位宽应该是一样的(即例如不存在线程 0 执行
LDS.32 而线程 1 执行
LDS.128),因此我们只需要对 64 位宽和 128
位宽的访存指令分别讨论即可。
64 位宽的访存指令
对于 64 位宽的访存指令而言,除非触发广播机制,否则一个 Warp 中有多少个活跃的 Half-Warp 就需要多少个 Memory Transaction,一个 Half-Warp 活跃的定义是这个 Half-Warp 内有任意一个线程活跃。触发广播机制只需满足以下条件中的至少一个:
- 对于 Warp 内所有活跃的第 i 号线程,第 i xor 1 号线程不活跃或者访存地址和其一致;
- 对于 Warp 内所有活跃的第 i 号线程,第 i xor 2 号线程不活跃或者访存地址和其一致;
如果触发了广播机制,那么两个 Half-Warp 内的 Memory Transaction 可以合并成一个。
我们看几个例子:
Case 1: 活跃线程全部在第 1 个 Half-Warp 内,需要 1 个 Memory Transaction,没有 Bank Conflict,需要 1 个 Wavefront
Case 2: 活跃线程分散在了 2 个 Half-Warp 内,需要 2 个 Memory Transaction,没有 Bank Conflict,需要 2 个 Wavefront(注意第 15 号和第 16 号线程)
Case 3: 活跃线程分散在了 2 个 Half-Warp 内,但因为触发了广播机制中的第一条,因此仍然只需要 1 个 Memory Transaction,没有 Bank Conflict,需要 1 个 Wavefront
Case 4: 活跃线程分散在了 2 个 Half-Warp 内,看似好像触发了广播机制,但其实并没有,因为第一个 Half-Warp 触发的是第一条,第二个 Half-Warp 触发的是第二条,因此仍然需要 2 个 Memory Transaction,没有 Bank Conflict,需要 2 个 Wavefront
Case 5: 活跃线程分散在了 2 个 Half-Warp 内,没有触发广播机制,需要 2 个 Memory Transaction,没有 Bank Conflict,需要 2 个 Wavefront
可以通过 Nsight Compute 跑一跑下面的代码并观察和 Shared Memory 的相关 Metric 来验证上面这五个例子:
1 |
|
上面的例子中并没有列举出有 Bank Conflict 的情况,那么 Bank Conflict 在这种情况下应该如何考虑呢?正如前面提到的那样,我们只需要计算每个 Memory Transaction 中的 Bank Conflict 数目然后加起来就好了(因为 Memory Transaction 是串行的)。
128 位宽的访存指令
128 位宽的访存指令和 64 位宽的访存指令是类似的,不同的是需要以 Half-Warp 为单位来计算,对于每个 Half-Warp 而言,除非触发广播机制,这个 Half-Warp 中有多少个活跃的 Quarter-Warp 就需要多少个 Memory Transaction,一个 Quarter-Warp 活跃的定义是这个 Quarter-Warp 内有任意一个线程活跃。类似地,如果触发广播机制那么两个 Quarter-Warp 中的 Transaction 就可以被合并成一个。 触发广播机制的条件和 64 位宽的访存指令是一样的(注意广播机制是以整个 Warp 为单位考虑)。这也就意味着假设一个 Warp 中 32 个线程都活跃,即使它们的访存地址都一样,也需要 2 个 Memory Transaction。
同样来看几个例子:
Case 1: 活跃线程分散在了 2 个 Half-Warp 和 2 个 Quarter-Warp 内,每个 Half-Warp 需要 1 个 Memory Transaction,总共需要 2 个 Memory Transaction,没有 Bank Conflict,需要 2 个 Wavefront
Case 2: 活跃线程分散在了 1 个 Half-Warp 和 2 个 Quarter-Warp 内,需要 1 个 Memory Transaction,没有 Bank Conflict,需要 1 个 Wavefront
Case 3: 活跃线程分散在了 2 个 Half-Warp 和 4 个 Quarter-Warp 内,但触发了广播机制(第一条),每个 Half-Warp 需要 1 个 Memory Transaction,总共需要 2 个 Memory Transaction,没有 Bank Conflict,需要 2 个 Wavefront
Case 4: 活跃线程分散在了 2 个 Half-Warp 和 4 个 Quarter-Warp 内,没有触发广播机制,每个 Half-Warp 需要 2 个 Memory Transaction,总共需要 4 个 Memory Transaction,没有 Bank Conflict,需要 4 个 Wavefront
Case 5: 活跃线程分散在了 2 个 Half-Warp 和 4 个 Quarter-Warp 内,但触发了广播机制(第一条和第二条),每个 Half-Warp 需要 1 个 Memory Transaction,总共需要 2 个 Memory Transaction,但因为每个 Memory Transaction 中有 1 个 Bank Conflict,因此会拆分成 4 个 Memory Transaction,对应需要 4 个 Wavefront
Case 6: 活跃线程分散在了 2 个 Half-Warp 和 4 个 Quarter-Warp 内,没有触发广播机制,每个 Half-Warp 需要 2 个 Memory Transaction,总共需要 4 个 Memory Transaction,没有 Bank Conflict,需要 4 个 Wavefront
同样可以通过 Nsight Compute 跑一跑下面的代码并观察和 Shared Memory 的相关 Metric 来验证上面这几个例子:
1 |
|
总结
可以看到实际上 32 位宽的访存指令和 64/128 位宽的访存指令的广播机制以及 Bank Conflict 的计算都有很大的不同,但是官方文档中没有出现相关的描述(或者我没看到😵💫)。这篇文章通过 Microbenchmark 以及前人的一些讨论总结了几套规则,但需要注意的是这些规则有一定的局限性,其一是我只评测了以上图片中的例子,因此是不清楚更加复杂的访存情况是不是仍然符合这些规则的,其二是这些规则不是官方记录的,因此很有可能在将来被新发布的 GPU 架构所改写。不过能清楚这些底层细节还是很有趣的 (大概~