CUDA编程---全局内存

时间:2024-04-14 15:04:27

CUDA内存模型概述

内存的访问和管理是所有编程语言的重要部分。在现代加速器中,内存管理对高性能计算有着很大的影响。因为多数工作负载被加载和存储数据的速度所限制,所以有大量低延迟、高带宽的内存对性能是十分有利的。
然而,大容量、高性能的内存造价高且不容易生产。因此,在现有的硬件存储子系统下,必须依靠内存模型获得最佳的延迟和带宽。CUDA内存模型结合了主机和设备的内存系统,展现了完整的内存层次结构,使用户能显式地控制数据布局以优化性能。

内存层次结构的优点

一般来说,应用程序不会在某一时间点访问任意数据或运行任意代码。应用程序往往遵循局部性原则,这表明它们可以在任意时间点访问相对较小的局部地址空间。有两种不同类型的局部性:

  • 时间局部性
  • 空间局部性

时间局部性认为如果一个数据位置被引用,那么该数据在较短的时间周期内很可能会再次被引用,随着时间流逝,该数据被引用的可能性逐渐降低。
空间局部性认为如果一个内存位置被引用,则附近的位置也可能会被引用。
CPU和GPU的主存都采用的是DRAM(动态随机存取存储器),而低延迟内存(如CPU一级缓存)使用的则是SRAM(静态随机存取存储器)。内存层次结构中最大且最慢的级别通常使用磁盘或闪存驱动来实现。在这种内存层次结构中,当数据被处理器频繁使用时,该数据保存在低延迟、低容量的存储器中;而当该数据被存储起来以备后用时,数据就存储在高延迟、大容量的存储器中。
这种内存层次结构符合大内存低延迟的设想。GPU与CPU在内存层次结构设计中都使用相似的准则和模型。GPU和CPU内存模型的主要区别是,CUDA编程模型能将内存层次结构更好地呈现给用户,能让我们显式地控制
它的行为。

CUDA内存模型

对于程序员来说,一般有两种类型的存储器:

  • 可编程的:你需要显式地控制哪些数据存放在可编程内存中
  • 不可编程的:你不能决定数据的存放位置,程序将自动生成存放位置以获得良好的性能

在CPU内存层次结构中,一级缓存和二级缓存都是不可编程的存储器。另一方面,CUDA内存模型提出了多种可编程内存的类型:

  • 寄存器
  • 共享内存
  • 本地内存
  • 常量内存
  • 纹理内存
  • 全局内存

下图展示了这些内存空间的层次结构,每种都有不同的作用域、生命周期和缓存行为。
一个核函数中的线程都有自己私有的本地内存一个线程块有自己的共享内存,对同一线程块中所有线程都可见,其内容持续线程块的整个生命周期所有线程都可以访问全局内存。所有线程都能访问的只读内存空间有:常量内存空间和纹理内存空间。全局内存、常量内存和纹理内存空间有不同的用途。纹理内存为各种数据布局提供了不同的寻址模式和滤波模式。对于一个应用程序来说,全局内存、常量内存和纹理内存中的内容具有
相同的生命周期。
在这里插入图片描述

寄存器

寄存器是GPU上运行速度最快的内存空间。核函数中声明的一个没有其他修饰符的自变量,通常存储在寄存器中。在核函数声明的数组中,如果用于引用该数组的索引是常量且能在编译时确定,那么该数组也存储在寄存器中。寄存器变量对于每个线程来说都是私有的,一个核函数通常使用寄存器来保存需要频繁访问的线程私有变量
寄存器变量与核函数的生命周期相同。一旦核函数执行完毕,就不能对寄存器变量进行访问了。
寄存器是一个在SM中由活跃线程束划分出的较少资源。在Fermi GPU中,每个线程限制最多拥有63个寄存器。Kepler GPU将该限制扩展至每个线程可拥有255个寄存器。在核函数中使用较少的寄存器将使在SM上有更多的常驻线程块。每个SM上并发线程块越多,使用率和性能就越高如果一个核函数使用了超过硬件限制数量的寄存器,则会用本地内存替代多占用的寄存器。这种寄存器溢出会给性能带来不利影响

本地内存

核函数中符合存储在寄存器中但不能进入被该核函数分配的寄存器空间中的变量将溢出到本地内存中。编译器可能存放到本地内存中的变量有:

  • 在编译时使用未知索引引用的本地数组
  • 可能会占用大量寄存器空间的较大本地结构体或数组
  • 任何不满足核函数寄存器限定条件的变量

“本地内存”这一名词是有歧义的:溢出到本地内存中的变量本质上与全局内存在同一块存储区域,因此本地内存访问的特点是高延迟和低带宽

共享内存

在核函数中使用如下修饰符修饰的变量存放在共享内存中:

__shared__

因为共享内存是片上内存,所以与本地内存或全局内存相比,它具有更高的带宽和更低的延迟。它的使用类似于CPU一级缓存,但它是可编程的
每一个SM都有一定数量的由线程块分配的共享内存。因此,必须非常小心不要过度使用共享内存,否则将在不经意间限制活跃线程束的数量。
共享内存在核函数的范围内声明,其生命周期伴随着整个线程块。当一个线程块执行结束后,其分配的共享内存将被释放并重新分配给其他线程块。共享内存是线程之间相互通信的基本方式。一个块内的线程通过使用共享内存中的数据可以相互合作
SM中的一级缓存和共享内存都使用64KB的片上内存,它通过静态划分,但在运行时可以通过如下指令进行动态配置:
在这里插入图片描述
这个函数在每个核函数的基础上配置了片上内存划分,为func指定的核函数设置了配置。支持的缓存配置如下:
在这里插入图片描述

常量内存

常量内存驻留在设备内存中,并在每个SM专用的常量缓存中缓存。常量变量用如下修饰符来修饰:
__constant__
常量变量必须在全局空间内和所有核函数之外进行声明。对于所有计算能力的设备,都只可以声明64KB的常量内存。常量内存是静态声明的,并对同一编译单元中的所有核函数可见
核函数只能从常量内存中读取数据。因此,常量内存必须在主机端使用下面的函数来初始化:
在这里插入图片描述
这个函数将count个字节从src指向的内存复制到symbol指向的内存中,这个变量存放在设备的全局内存或常量内存中。在大多数情况下这个函数是同步的。
线程束中的所有线程从相同的内存地址中读取数据时,常量内存表现最好。举个例子,数学公式中的系数就是一个很好的使用常量内存的例子,因为一个线程束中所有的线程使用相同的系数来对不同数据进行相同的计算。如果线程束里每个线程都从不同的地址空间读取数据,并且只读一次,那么常量内存中就不是最佳选择,因为每从一个常量内存中读取一次数据,都会广播给线程束里的所有线程。

纹理内存

纹理内存驻留在设备内存中,并在每个SM的只读缓存中缓存。纹理内存是一种通过指定的只读缓存访问的全局内存。只读缓存包括硬件滤波的支持,它可以将浮点插入作为读过程的一部分来执行。
纹理内存是对二维空间局部性的优化,所以线程束里使用纹理内存访问二维数据的线程可以达到最优性能。对于一些应用程序来说,这是理想的内存,并由于缓存和滤波硬件的支持所以有较好的性能优势。然而对于另一些应用程序来说,与全局内存相比,使用纹理内存更慢。

全局内存

全局内存是GPU中最大、延迟最高并且最常使用的内存。global指的是其作用域和生命周期。它的声明可以在任何SM设备上被访问到,并且贯穿应用程序的整个生命周期
一个全局内存变量可以被静态声明或动态声明。你可以使用如下修饰符在设备代码中静态地声明一个变量:
__device__
全局内存常驻于设备内存中,可通过32字节、64字节或128字节的内存事务进行访问。这些内存事务必须自然对齐,也就是说,首地址必须是32字节、64字节或128字节的倍数。
优化内存事务对于获得最优性能来说是至关重要的。当一个线程束执行内存加载/存储时,需要满足的传输数量通常取决于以下两个因素:

  • 跨线程的内存地址分布
  • 每个事务内存地址的对齐方式

在一般情况下,用来满足内存请求的事务越多,未使用的字节被传输回的可能性就越高,这就造成了数据吞吐率的降低。对于一个给定的线程束内存请求,事务数量和数据吞吐率是由设备的计算能力来确定的。对于计算能力为1.0和1.1的设备,全局内存访问的要求是非常严格的。对于计算能力高于1.1的设备,由于内存事务被缓存,所以要求较为宽松。缓存的内存事务利用数据局部性来提高数据吞吐率

GPU缓存

跟CPU缓存一样,GPU缓存是不可编程的内存。在GPU上有4种缓存:

  • 一级缓存
  • 二级缓存
  • 只读常量缓存
  • 只读纹理缓存

每个SM都有一个一级缓存,所有的SM共享一个二级缓存。一级和二级缓存都被用来在存储本地内存和全局内存中的数据,也包括寄存器溢出的部分。对Fermi GPU和Kepler K40或其后发布的GPU来说,CUDA允许我们配置读操作的数据是使用一级和二级缓存,还是只使用二级缓存
在CPU上,内存的加载和存储都可以被缓存。但是,在GPU上只有内存加载操作可以被缓存,内存存储操作不能被缓存。
每个SM也有一个只读常量缓存和只读纹理缓存,它们用于在设备内存中提高来自于各自内存空间内的读取性能。

CUDA变量声明总结

在这里插入图片描述
+既可以表明标量也可以表示数组。
在这里插入图片描述
+只在计算能力2.x的设备上进行缓存。

内存管理

内存分配和释放

CUDA编程模型假设了一个包含一个主机和一个设备的异构系统,每一个异构系统都有自己独立的内存空间。核函数在设备内存空间中运行,CUDA运行时提供函数以分配和释放设备内存。你可以在主机上使用下列函数分配全局内存:
在这里插入图片描述
这个函数在设备上分配了count字节的全局内存,并用devptr指针返回该内存的地址。所分配的内存支持任何变量类型,包括整型、浮点类型变量、布尔类型等。如果cudaMalloc函数执行失败则返回cudaErrorMemoryAllocation。在已分配的全局内存中的值不会被清除。你需要用从主机上传输的数据来填充所分配的全局内存,或用下列函数将其初始化:
在这里插入图片描述
这个函数用存储在变量value中的值来填充从设备内存地址devPtr处开始的count字节。一旦一个应用程序不再使用已分配的全局内存,那么可以以下代码释放该内存空间:
在这里插入图片描述
这个函数释放了devPtr指向的全局内存,该内存必须在此前使用了一个设备分配函数(如cudaMalloc)来进行分配。否则,它将返回一个错误cudaErrorInvalidDevicePointer。如果地址空间已经被释放,那么cudaFree也返回一个错误。设备内存的分配和释放操作成本较高,所以应用程序应重利用设备内存,以减少对整体性能的影响。

内存传输

一旦分配好了全局内存,你就可以使用下列函数从主机向设备传输数据:
在这里插入图片描述
这个函数从内存位置src复制了count字节到内存位置dst。变量kind指定了复制的方向,可以有下列取值:
在这里插入图片描述
如果指针dst和src与kind指定的复制方向不一致,那么cudaMemcpy的行为就是未定义行为。这个函数在大多数情况下都是同步的。

GPU芯片和板载GDDR5 GPU内存之间的理论峰值带宽非常高,对于Fermi C2050 GPU来说为144GB/s。CPU和GPU之间通过PCIe Gen2总线相连,这种连接的理论带宽要低得多,为8GB/s(PCIe Gen3总线最大理论限制值是16GB/s)。这种差距意味着如果管理不当的话,主机和设备间的数据传输会降低应用程序的整体性能。因此,CUDA编程的一个基本原则应是尽可能地减少主机与设备之间的传输

固定内存

分配的主机内存默认是pageable(可分页),它的意思也就是因页面错误导致的操作,该操作按照操作系统的要求将主机虚拟内存上的数据移动到不同的物理位置。虚拟内存给人一种比实际可用内存大得多的假象,就如同一级缓存好像比实际可用的片上内存大得多一样。

GPU不能在可分页主机内存上安全地访问数据,因为当主机操作系统在物理位置上移动该数据时,它无法控制。当从可分页主机内存传输数据到设备内存时,CUDA驱动程序首先分配临时页面锁定的或固定的主机内存,将主机源数据复制到固定内存中,然后从固定内存传输数据给设备内存。
在这里插入图片描述
CUDA运行时允许你使用如下指令直接分配固定主机内存:
在这里插入图片描述
这个函数分配了count字节的主机内存,这些内存是页面锁定的并且对设备来说是可访问的。由于固定内存能被设备直接访问,所以它能用比可分页内存高得多的带宽进行读写。然而,分配过多的固定内存可能会降低主机系统的性能,因为它减少了用于存储虚拟内存数据的可分页内存的数量,其中分页内存对主机系统是可用的。
固定主机内存必须通过下述指令来释放:在这里插入图片描述

零拷贝内存

通常来说,主机不能直接访问设备变量,同时设备也不能直接访问主机变量。但有一个例外:零拷贝内存。主机和设备都可以访问零拷贝内存
GPU线程可以直接访问零拷贝内存。在CUDA核函数中使用零拷贝内存有以下几个优势。

  • 当设备内存不足时可利用主机内存
  • 避免主机和设备间的显式数据传输
  • 提高PCIe传输率

当使用零拷贝内存来共享主机和设备间的数据时,你必须同步主机和设备间的内存访问,同时更改主机和设备的零拷贝内存中的数据将导致不可预知的后果。

统一虚拟寻址

计算能力为2.0及以上版本的设备支持一种特殊的寻址方式,称为统一虚拟寻址(UVA)。UVA,在CUDA 4.0中被引入,支持64位Linux系统。有了UVA,主机内存和设备内存可以共享同一个虚拟地址空间,如图所示:
在这里插入图片描述

内存访问模式

大多数设备端数据访问都是从全局内存开始的,并且多数GPU应用程序容易受内存带宽的限制。因此,最大限度地利用全局内存带宽是调控核函数性能的基本。如果不能正确地调控全局内存的使用,其他优化方案很可能也收效甚微。

为了在读写数据时达到最佳的性能,内存访问操作必须满足一定的条件。CUDA执行模型的显著特征之一就是指令必须以线程束为单位进行发布和执行。存储操作也是同样。在执行内存指令时,线程束中的每个线程都提供了一个正在加载或存储的内存地址。在线程束的32个线程中,每个线程都提出了一个包含请求地址的单一内存访问请求,它并由一个或多个设备内存传输提供服务。根据线程束中内存地址的分布,内存访问可以被分成不同的模式。

对齐与合并访问

如图所示,全局内存通过缓存来实现加载/存储。全局内存是一个逻辑内存空间,可以通过核函数访问它。所有的应用程序数据最初存在于DRAM上,即物理设备内存中。核函数的内存请求通常是在DRAM设备和片上内存间以128字节32字节内存事务来实现的。
所有对全局内存的访问都会通过二级缓存,也有许多访问会通过一级缓存,这取决于访问类型和GPU架构。如果这两级缓存都被用到,那么内存访问是由一个128字节的内存事务实现的。如果只使用了二级缓存,那么这个内存访问是由一个32字节的内存事务实现的。对全局内存缓存其架构,如果允许使用一级缓存,那么可以在编译时选择启用或禁用一级缓存。
一行一级缓存是128个字节,它映射到设备内存中一个128字节的对齐段。如果线程束中的每个线程请求一个4字节的值,那么每次请求就会获取128字节的数据,这恰好与缓存行和设备内存段的大小相契合。
因此在优化应用程序时,你需要注意设备内存访问的两个特性:

  • 对齐内存访问
  • 合并内存访问
    在这里插入图片描述

当设备内存事务的第一个地址是用于事务服务的缓存粒度的整数倍时(32字节的二级缓存或128字节的一级缓存),就会出现对齐内存访问。运行非对齐的加载会造成带宽浪费。
当一个线程束中全部的32个线程访问一个连续的内存块时,就会出现合并内存访问
对齐合并内存访问的理想状态是线程束从对齐内存地址开始访问一个连续的内存块。为了最大化全局内存吞吐量,为了组织内存操作进行对齐合并是很重要的。下图1描述了对齐与合并内存的加载操作。在这种情况下,只需要一个128字节的内存事务从设备内存中读取数据。下图2展示了非对齐和未合并的内存访问。在这种情况下,可能需要3个128字节的内存事务来从设备内存中读取数据:一个在偏移量为0的地方开始,读取连续地址之后的数据;一个在偏移量为256的地方开始,读取连续地址之前的数据;另一个在偏移量为128的地方开始读取大量的数据。注意在内存事务之前和之后获取的大部分字节将不能被使用,这样会造成带宽浪费
对齐且合并的内存访问
一般来说,需要优化内存事务效率:用最少的事务次数满足最多的内存请求。事务数
量和吞吐量的需求随设备的计算能力变化。
在这里插入图片描述

全局内存读取

在SM中,数据通过以下3种缓存/缓冲路径进行传输,具体使用何种方式取决于引用了哪种类型的设备内存:

  • 一级和二级缓存
  • 常量缓存
  • 只读缓存

一/二级缓存是默认路径。想要通过其他两种路径传递数据需要应用程序显式地说明,但要想提升性能还要取决于使用的访问模式。全局内存加载操作是否会通过一级缓存取决于两个因素:

  • 设备的计算能力
  • 编译器选项

在Fermi GPU(计算能力为2.x)和Kepler K40及以后的GPU(计算能力为3.5及以上)中,可以通过编译器标志启用或禁用全局内存负载的一级缓存。默认情况下,在Fermi设备上对于全局内存加载可以用一级缓存,在K40及以上GPU中禁用。以下标志通知编译器禁用一级缓存:
在这里插入图片描述
如果一级缓存被禁用,所有对全局内存的加载请求将直接进入到二级缓存;如果二级缓存缺失,则由DRAM完成请求。每一次内存事务可由一个、两个或四个部分执行,每个部分有32个字节。一级缓存也可以使用下列标识符直接启用:
在这里插入图片描述
设置这个标志后,全局内存加载请求首先尝试通过一级缓存。如果一级缓存缺失,该请求转向二级缓存。如果二级缓存缺失,则请求由DRAM完成。在这种模式下,一个内存加载请求由一个128字节的设备内存事务实现。
在Kepler K10、K20和K20X GPU中,一级缓存不用来缓存全局内存加载。一级缓存专门用于缓存寄存器溢出到本地内存中的数据。
内存加载访问模式
内存加载可以分为两类:

  • 缓存加载(启用一级缓存)
  • 没有缓存的加载(禁用一级缓存)

内存加载的访问模式有如下特点:

  • 有缓存与没有缓存:如果启用一级缓存,则内存加载被缓存
  • 对齐与非对齐:如果内存访问的第一个地址是32字节的倍数,则对齐加载。
  • 合并与未合并:如果线程束访问一个连续的数据块,则加载合并
缓存加载

缓存加载操作经过一级缓存,在粒度为128字节的一级缓存行上由设备内存事务进行传输。缓存加载可以分为对齐/非对齐合并/非合并
下图所示为理想情况:对齐与合并内存访问。线程束中所有线程请求的地址都在128字节的缓存行范围内。完成内存加载操作只需要一个128字节的事务。总线的使用率为100%,在这个事务中没有未使用的数据。
在这里插入图片描述
下图所示为另一种情况:访问是对齐的,引用的地址不是连续的线程ID,而是128字节范围内的随机值。由于线程束中线程请求的地址仍然在一个缓存行范围内,所以只需要一个128字节的事务来完成这一内存加载操作。总线利用率仍然是100%,并且只有当每个线程请求在128字节范围内有4个不同的字节时,这个事务中才没有未使用的数据。
在这里插入图片描述
下图也说明了一种情况:线程束请求32个连续4个字节的非对齐数据元素。在全局内存中线程束的线程请求的地址落在两个128字节段范围内。因为当启用一级缓存时,由SM执行的物理加载操作必须在128个字节的界线上对齐,所以要求有两个128字节的事务来执行这段内存加载操作。总线利用率为50%,并且在这两个事务中加载的字节有一半是未使用的。
在这里插入图片描述
下图说明了一种情况:线程束中所有线程请求相同的地址。因为被引用的字节落在一个缓存行范围内,所以只需请求一个内存事务,但总线利用率非常低。如果加载的值是4字节的,则总线利用率是4字节请求/128字节加载=3.125%。
在这里插入图片描述
下图所示为最坏的情况:线程束中线程请求分散于全局内存中的32个4字节地址。尽管线程束请求的字节总数仅为128个字节,但地址要占用N个缓存行(0<N ≤ 32)。完成一次内存加载操作需要申请N次内存事务。
在这里插入图片描述
CPU一级缓存和GPU一级缓存之间的差异:CPU一级缓存优化了时间和空间局部性。GPU一级缓存是专为空间局部性而不是为时间局部性设计的。频繁访问一个一级缓存的内存位置不会增加数据留在缓存中的概率。

没有缓存的加载

没有缓存的加载不经过一级缓存,它在内存段的粒度上(32个字节)而非缓存池的粒度(128个字节)执行。这是更细粒度的加载,可以为非对齐或非合并的内存访问带来更好的总线利用率。
下图所示为理想情况:对齐与合并内存访问。128个字节请求的地址占用了4个内存段,总线利用率为100%。
在这里插入图片描述
下图说明了一种情况:内存访问是对齐的且线程访问是不连续的,而是在128个字节的范围内随机进行。只要每个线程请求唯一的地址,那么地址将占用4个内存段,并且不会有加载浪费。这样的随机访问不会抑制内核性能。
在这里插入图片描述
下图说明了一种情况:线程束请求32个连续的4字节元素但加载没有对齐到128个字节的边界。请求的地址最多落在5个内存段内,总线利用率至少为80%。与这些类型的请求缓存加载相比,使用非缓存加载会提升性能,这是因为加载了更少的未请求字节。
在这里插入图片描述
下图说明了一种情况:线程束中所有线程请求相同的数据。地址落在一个内存段内,总线的利用率是请求的4字节/加载的32字节=12.5%,在这种情况下,非缓存加载性能也是优于缓存加载的性能。
在这里插入图片描述
下图说明了最坏的一种情况:线程束请求32个分散在全局内存中的4字节字。由于请求的128个字节最多落在N个32字节的内存分段内而不是N个128个字节的缓存行内,所以相比于缓存加载,即便是最坏的情况也有所改善。
在这里插入图片描述

只读缓存

只读缓存最初是预留给纹理内存加载使用的。对计算能力为3.5及以上的GPU来说,只读缓存也支持使用全局内存加载代替一级缓存。只读缓存的加载粒度是32个字节。通常,对分散读取来说,这些更细粒度的加载要优于一级缓存。
有两种方式可以指导内存通过只读缓存进行读取:

  • 使用函数__ldg
  • 在间接引用的指针上使用修饰符

全局内存写入

内存的存储操作相对简单。一级缓存不能用在Fermi或Kepler GPU上进行存储操作,在发送到设备内存之前存储操作只通过二级缓存。存储操作在32个字节段的粒度上被执行。内存事务可以同时被分为一段、两段或四段。例如,如果两个地址同属于一个128个字节区域,但是不属于一个对齐的64个字节区域,则会执行一个四段事务(也就是说,执行一个四段事务比执行两个一段事务效果更好)。
下图所示为理想情况:内存访问是对齐的,并且线程束里所有的线程访问一个连续的128字节范围。存储请求由一个四段事务实现。在这里插入图片描述
下图所示为内存访问是对齐的,但地址分散在一个192个字节范围内的情况。存储请求由3个一段事务来实现。
在这里插入图片描述
下图所示为内存访问是对齐的,并且地址访问在一个连续的64个字节范围内的情况。这种存储请求由一个两段事务来完成。在这里插入图片描述