CUDA从入门到放弃(六):CUDA内存结构(Memory Hierarchy)
CUDA线程在执行过程中可以从多个内存空间访问数据。每个线程都有私有的局部内存。每个线程块具有共享内存,该内存对所有线程块内的线程可见,并且与线程块具有相同的生命周期。线程块集群中的线程块可以相互执行对共享内存的读取、写入和原子操作。所有线程都可以访问相同的全局内存。
此外,还有两个所有线程都可以访问的只读内存空间:常量内存空间和纹理内存空间。
对于同一应用程序来说,全局内存、常量内存和纹理内存空间在内核启动之间是持久的。
1 全局内存 Global Memory
全局内存位于设备内存中,通过32字节、64字节或128字节的内存事务进行访问。这些内存事务必须自然对齐:只有对齐到其大小(即,其首地址是其大小的倍数)的设备内存的32字节、64字节或128字节段才能通过内存事务进行读取或写入。
当warp执行访问全局内存的指令时,它会根据每个线程访问的字的大小以及内存地址在线程之间的分布,将warp内部线程的内存访问合并为一个或多个这些内存事务。一般来说,所需的交易越多,除了线程访问的字之外,传输的未使用字就越多,从而相应地降低了指令吞吐量。例如,如果为每个线程的4字节访问生成一个32字节的内存事务,则吞吐量将减少到原来的八分之一。
大小和对齐要求
全局内存指令支持读写大小为1、2、4、8或16字节的数据。只有当数据类型大小符合这些值,并且数据自然对齐(地址是大小的倍数)时,访问才会编译为单个全局内存指令。
若不满足此要求,访问将编译为多个具有交错访问模式的指令,导致指令无法完全合并。因此,建议使用满足这些要求的类型来处理全局内存中的数据。
内置向量类型会自动满足对齐要求。对于结构体,可以使用__align__(8)或__align__(16)来确保对齐。
struct __align__(8) {
float x;
float y;
};
struct __align__(16) {
float x;
float y;
float z;
};
全局内存中的变量地址或由相关API返回的地址至少对齐到256字节。读取非自然对齐的8或16字节数据会产生错误结果,因此需特别注意保持对齐。特别是在使用自定义的全局内存分配方案时,应确保每个数组的起始地址正确对齐。
2 局部内存 Local Memory
局部内存空间位于设备内存中,因此局部内存访问与全局内存访问具有相同的高延迟和低带宽,并且必须满足与设备内存访问中描述的相同内存合并要求。然而,局部内存的组织方式是,连续的32位字由连续的线程ID访问。因此,只要warp中的所有线程访问相同的相对地址(例如,数组变量中的相同索引,结构变量中的相同成员),访问就会完全合并。
局部内存访问仅发生在某些自动变量上。编译器可能将以下自动变量放置在局部内存中:
- 它无法确定使用常量索引访问的数组,
- 会消耗过多寄存器空间的大型结构或数组,
- 如果内核使用的寄存器数量超过可用数量(这也称为寄存器溢出)时的任何变量。
3 共享内存 Shared Memory
由于共享内存位于芯片上,因此它相比本地内存或全局内存具有更高的带宽和更低的延迟。
为了实现高带宽,共享内存被分割成大小相等的内存模块,称为内存bank,这些bank可以同时访问。因此,任何由n个不同内存bank中的地址组成的内存读或写请求都可以同时得到服务,从而得到比单个模块带宽高出n倍的整体带宽。
然而,如果内存请求的两个地址落在同一个内存银行中,就会发生bank冲突,并且访问必须串行化。硬件会将带有bank冲突的内存请求拆分成尽可能多的单独的无冲突请求,吞吐量将降低一个等于单独内存请求数量的因子。如果单独的内存请求数量是n,那么初始内存请求就被认为是造成了n路bank冲突。
4 常量内存 Constant Memory
常量内存空间位于设备内存中,并缓存在常量缓存中。
然后,请求会根据初始请求中不同内存地址的数量拆分成多个单独的请求,吞吐量将降低一个等于单独请求数量的因子。
在缓存命中的情况下,生成的请求将以常量缓存的吞吐量进行处理;否则,将以设备内存的吞吐量进行处理。
5 纹理和表面内存 Texture and Surface Memory
纹理和表面内存空间位于设备内存中,并被缓存在纹理缓存中。因此,只有在缓存未命中的情况下,纹理获取或表面读取才会从设备内存中进行一次内存读取,否则仅从纹理缓存中进行一次读取。纹理缓存针对二维空间局部性进行了优化,因此,在二维空间中读取纹理或表面地址相近的同一warp中的线程将实现最佳性能。此外,它还设计用于具有恒定延迟的流式获取;缓存命中可以减少对DRAM带宽的需求,但不会减少获取延迟。
通过纹理或表面获取读取设备内存具有一些优势,这使得它成为从全局或常量内存读取设备内存的有利替代方案:
如果内存读取不遵循全局或常量内存读取必须遵循的访问模式以获得良好性能,那么只要纹理获取或表面读取中存在局部性,就可以实现更高的带宽;
寻址计算由专用单元在内核外部执行;
可以通过单个操作将打包的数据广播到单独的变量中;
8位和16位整数输入数据可以选择性地转换为范围在[0.0, 1.0]或[-1.0, 1.0]内的32位浮点数值。
参考资料
1 CUDA编程入门
2 CUDA编程入门极简教程
3 CUDA C++ Programming Guide
4 CUDA C++ Best Practices Guide
5 NVIDIA CUDA初级教程视频
6 CUDA专家手册 [GPU编程权威指南]
7 CUDA并行程序设计:GPU编程指南
8 CUDA C编程权威指南