大家好,我是Tim。
在我们之前介绍flashAttention的实现原理的时候,就介绍了在Cuda中利用高性能内存编程来加速算子的性能。
允许自由的选择要使用的内存这也是Cuda编程令人着迷的地方,虽然说最近几年越来越多自动化生成高性能Cuda语言的框架层出不穷,使得我们越来越少的编写Cuda代码。
但了解GPU 内的各种类型的内存,以及每种类型的具体用途,可以充分利用不同的内存类型来优化我们的程序。
GPU 中的内存类型
在深入研究 GPU 内的各种内存类型之前,重要的是要了解关于内存的定义。
当我们谈论内存时,我们通常将其分为两种主要类型:物理内存和逻辑内存。
物理内存:这是指计算机中实际的硬件内存。它包括 RAM 模块等组件和硬盘驱动器 (HDD/SSD) 等存储设备。物理内存是直接存储数据和程序的地方,可以被处理器快速访问。
逻辑内存(虚拟内存):这是操作系统和程序可以访问的地址空间。逻辑内存不一定与物理内存直接一一对应。操作系统通常管理逻辑地址和物理地址之间的映射。这种管理有助于为系统上运行的程序分配和管理内存。
那么GPU中有哪些内存,以及是如何分布的呢?下面我们先来了解下GPU逻辑内存视图。
GPU逻辑内存

首先,要明确的是块和线程是逻辑概念。
其次,在我们继续之前,我们首先需要记住scope这个概念,我们称为范围,它对于理解如何在 GPU 的逻辑内存中分配和管理线程和块等资源起着至关重要的作用。
本地内存(Local Memory): 每个线程都可以使用自己的本地内存,可以在其中存储临时变量。它具有最小的范围并且专用于每个单独的线程。
共享内存(Shared Memory):同一个Block内的线程可以通过共享内存共享数据。与访问全局内存相比,这允许同一块内的线程更快地通信和访问数据。
全局内存(Global Memory):这是GPU中最大的内存,可以被所有块上的所有线程访问。然而,访问全局内存通常比其他内存类型慢,因此需要进行优化以避免性能下降。
-
纹理内存和常量内存(Texture and Constant Memory):这些是GPU 中的特殊内存类型,针对访问特定数据类型(例如纹理或常量值)进行了优化。所有块中的所有线程都可以访问这些内存类型
每个内存其对应的Cuda编程的变量声明和访问代码范围如下:

GPU物理内存

物理内存与逻辑内存存在着对应关系,在讨论物理内存中最重要的两个概念是流式多处理器(SM)和流式处理器(SP)。
每个SM 都拥有自己专用的共享内存、高速缓存、常量内存和寄存器内存。然而,多个SM共享相同的全局存储器。
在这种安排中,SM可以有效地管理自己的本地资源,例如用于块内通信的共享内存,并且每个SM的处理元件(SP)可以独立地完成分配的任务。全局内存的共享允许不同SM之间的协调和数据交换,使它们能够协同处理更大的计算任务。
接下来,我们再来了解下内存类型的数据访问速度。
内存带宽

首先,GPU一般以外挂设备的方式进行使用,只有当调用了Cuda算子时,才会将数据拷贝到GPU中的内存,然后执行计算。
所以,GPU的通信分为,GPU外部的Host到Device的通信和GPU内部通信两部分。
1. Pcle
正如我之前提到的,CPU(主机)和GPU(设备)是两个独立的组件,每个组件都有自己的内存,并且它们之间无法直接访问。数据必须通过PCIe(外围组件互连快速总线)(一种众所周知的接口)来回复制。
决定是否将数据从CPU移动到GPU进行计算的关键因素之一是PCIe,如图所示,它的数据传输速度最慢。
为了解决将大量数据从CPU复制到GPU的挑战,NVIDIA推出了三种方法:
统一内存 (Unified memory)
固定内存 (Pinned memory)
流式处理(hidden latency)
这些方法将在接下来的文章中更详细地讨论。但是,如果您好奇,可以浏览 NVIDIA 的文章:How to Optimize Data Transfers in CUDA C/C++
2. Global memory
全局内存(也称为设备内存)是GPU内最大的内存,即我常说的HBM内存。
然而其访问速度和其名称HBM(High Bandwidth Memory)高带宽内存是不符的,它在访问延迟方面仅优于PCIe的访问速度。
其实HBM内存就是通过将多个DRAM芯片垂直堆叠在一起来实现高密度存储,由于芯片的堆叠设计,HBM内存可以同时访问多个芯片,从而提供更高的并行性和带宽。
GPU 中的全局内存类似于 CPU 中的 RAM。当我们在GPU中初始化一个值而不指定其存储位置时,它会自动存储在全局内存中。
从这一点来看,很明显全局内存HBM的主要目的是存储大量数据。
3. Shared/Cache memory
共享内存和缓存是访问速度较快的内存类型,但与全局内存相比,它们的容量较小。
由于共享内存和缓存内存提供快速的访问速度,因此我们经常在计算过程中使用它们来存储数据。
典型的方法是首先将所有数据从 CPU 复制到 GPU 并将其存储在全局内存中。然后,我们将数据分解成更小的部分(块)并将它们推送到共享内存中进行计算。
计算完成后,结果将被推回全局内存。
4. Texture/Constant Memory
如前所述,纹理内存和常量内存是 GPU 中的特殊内存类型,针对访问特定数据类型(例如图像(纹理)或常量值)进行了优化。
这两种内存类型的访问速度都相当快,可以与共享内存相媲美。
因此,使用纹理内存和常量内存的目的是优化数据访问并减少共享内存的计算负载。
我们可以将一部分数据分配给纹理内存和常量内存,而不是将所有数据推送到共享内存中。
这种分配策略通过利用纹理内存和常量内存的优化访问功能来帮助增强内存性能。
5. 为什么SRAM和L1Cache被视为同一个内存区域
到这里我们介绍完了上图中的所有内存组件,不过不知道你是否注意到了上图中共享内存和 L1 缓存被合并画为单个内存(仅用虚线相分割)。
其实,如果我们的视角是物理视角,那么可以肯定共享内存和L1是两个独立的内存条。然而如果从Cuda编程视角或者说逻辑视角看,它们将是一个内存。
如果在逻辑视角上再将其分成不同的内存区域,那么管理起来会很困难,而且会非常耗费资源。
为什么管理起来困难呢,我举个预取的例子。
预取是在需要优化数据访问性能之前将数据从主内存加载到缓存或中间内存的过程

在上面的代码片段中,我们在两个数组“a”和“b”之间执行加法,以将结果存储在数组“c”中。
然而,在金典的访问模式中,程序将逐一迭代每个元素,并且每次访问时,它都会调整指向主存储器的指针以从“a”和“b”获取数据。这可能会导致从主内存访问数据时需要等待时间。
预取机制提出了一种减少等待时间的智能方法。它不是等到每个元素被单独访问,而是根据先前的访问模式预测程序可能执行的即将到来的数据访问。
然后,它将这些数据预取到高速缓存中,有助于减少等待时间并优化整体程序性能。
回到我们最初的疑问“难以管理 - 资源密集型”:
难以管理:如果共享内存和 L1 是分开的,我们将需要额外的映射步骤来确定接下来要访问哪些数据。当它们组合在单个内存机制中时,可以在共享内存和 L1 缓存之间有效地共享数据。
资源密集型:如果我们将它们分成两个独立的内存,那么在实现代码时,大多数时候,共享内存或缓存将无法得到充分利用(会有一些剩余空间)。但是,如果我们将它们结合起来,我们可以准确地分配我们需要的共享内存,其余的可以分配给缓存,从而提高效率。
为什么L1设计为局部线程的Cache,而L2为全局的Cache?
有了上面的解答这个疑问就简单了,这是因为全局内存也需要预取机制,这迫使我们将缓存分为两部分:共享(L1)和全局(L2)。
总结
GPU逻辑内存包括本地内存、共享内存、全局内存、纹理内存和常量内存,它们在GPU中的作用和访问方式不同。
GPU物理内存包括寄存器,SRAM,L1Cache, L2 Cache, HBM等。
内存的访问速度和内存的带宽是影响GPU计算的重要因素:RMEM(寄存器) > SMEM(共享存储) > CMEM(常量存储) > TMEM(类常量存储) > LMEM(本地存储) > GMEM(全局存储)> Pcle。
在CUDA编程中,共享内存和L1缓存被视为同一个内存区域。这样做的主要原因是为了更好地支持数据的预取机制,另外需要注意的是L1为局部线程的Cache,而L2为全局的Cache。