4.1 CUDA内存模型概述
内存访问和管理是所有编程语言的重要部分。
因为多数工作负载被加载和存储数据的速度所限制,所以有大量低延迟、高带宽的内存对性能是十分有利的。
大容量、低延迟的内存造价高且不易生产。故在现有的硬件存储子系统下,必须依靠内存模型获得最佳的延迟和带宽。
CUDA内存模型结合了主机和设备的内存系统,展现了完整的内存层次结构,使你能显式地控制数据布局以优化性能。
4.1.1 内存层次结构的优点
应用程序往往遵循局部性原则,这表明它们可以在任意时间点访问相对较小的局部地址空间。有两种不同类型的局部性:时间局部性和空间局部性;
时间局部性认为如果一个数据位置被引用,那么该数据在较短的时间周期内很可能会再次被引用,随着时间流逝,该数据被引用的可能性逐渐降低。
空间局部性认为如果一个内存位置被引用,则附近的位置也可能会被引用。
现代计算机使用不断改进的低延迟低容量的内存层次结构来优化性能。此内存结构仅在支持局部性原则下有效。一个内存层次结构由不同延迟、带宽和容量的多级内存组成。
寄存器 |
缓存 |
内存 |
磁盘 |
从上往下走,特点如下;
1. 更高的容量;
2. 更高的延迟;
3. 更少的处理器访问频率;
4. 更低的每比特位的平均成本;
当数据被处理器频繁使用时,该数据应保存在低延迟、低容量的存储器中。
当数据被存储起来以备后用时,该数据应保存在高延迟、高容量的存储器中。
4.1.2 CUDA内存模型
对于程序员而言,一般有两种类型的存储器:
1. 可编程的:需要显式地控制那些数据存放在可编程的内存中;
2. 不可编程的:不能决定数据的存放位置,程序将自动生成存放位置以获得良好的性能。
在CPU内存层次结构中,一级缓存和二级缓存都是不可编程的。另一方面,CUDA内存模型提出了多种可编程内存的类型:
1. 寄存器:线程可见;
2. 共享内存:线程块可见;
3. 本地内存:线程可见;
4. 常量内存:所有线程;
5. 纹理内存:所有线程;
6. 全局内存:所有线程;
纹理内存为各种数据布局提供了不同的寻址模式和滤波模式。对于一个应用程序来说,全局内存、常量内存和纹理内存的内容具有相同的生命周期。
4.1.2.1 寄存器
寄存器是GPU上运行速度最快的内存空间。核函数声明的一个没有其他修饰符的自变量,通常存储在寄存器中。
寄存器对于每个线程来说都是私有的,一个核函数通常使用寄存器来保存需要频繁访问的线程私有变量。
寄存器变量与核函数的生命周期相同。一旦核函数执行完毕,就不能对寄存器变量进行访问了。
寄存器是一个在SM中由活跃线程束划分出的较少资源。在Fermi,每个线程限制最多使用63个寄存器。在Kepler中,将限制扩展至最多255个寄存器。
在核函数中使用较少的寄存器将使SM上有更多的常驻线程块。每个SM上并发线程块越多,使用率和性能就越高。
下面命令会输出寄存器的数量、共享内存的字节数和每个线程所使用的常量内存的字节数:
-Xptxas -v, -abi=no
如果一个核函数使用超过硬件限制数量的寄存器,则会用本地内存代替多占用的寄存器。这种寄存器溢出会给性能带来不利影响。
nvcc编译器使用启发式策略来最小化寄存器的使用,以避免寄存器的溢出。也可以在代码中为每个核函数显式地加上额外的信息来帮助寄存器进行优化;
__global__ void __launch_bounds__(maxThreadsPerBlock, minBlocksPerMultiprocessor)
kernel__name(...)
{
...
}
maxThreadsPerBlock指出了每个线程块可以包含的最大线程数,这个线程块由核函数来启动。
minBlockPerMultiprocessor指出了在每个SM中预期的最小的常驻线程块数量。
也可以使用maxrregcount编译选项,来控制一个编译单元里所有核函数使用的寄存器的最大数量。
--maxrregcount=32;
4.1.2.2 本地内存
核函数中符合存储在寄存器中,但不能进入被核函数分配寄存器空间中的变量将溢出到本地内存。编译器可能存放在本地内存中的变量有;
1. 在编译时使用未知索引引用的本地数组;
2. 可能会占用大量寄存器空间的较大本地结构体或数组;
3. 任何不满足核函数寄存器限定条件的变量;
本地内存这一名词是有歧义的:溢出到本地内存的变量本质上与全局内存空间存放在同一块存储区域,因此本地内存的访问的特点是高延迟和低带宽。但是对于计算能力2.0及以上的GPU,本地内存数据也是存储在SM的L1缓存和设备的L2缓存中。
4.1.2.3 共享内存
在核函数中使用__shared__修饰符的变量存放在在共享内存中。生命周期伴随着整个线程块。当一个线程块执行结束后,分配的共享内存将释放并重新分配其他线程块。
每一个SM都有一定数量的由线程块分配的共享内存。因此,必须非常小心不要过度使用共享内存,否则将在不经意间限制活跃线程束的数量。
共享内存是线程之间相互通信的基本方式。一个块内的线程通过使用共享内存中的数据相互合作。
访问共享内存必须同步使用如下调用:__syncthreads(),该函数设立了一个执行路障点,即同一个线程块中的所有线程必须在其他线程被允许执行前到达该处。为线程块里所有线程设立障碍点,这样可以避免潜在的数据冲突。
__syncthreads()也会通过频繁强制SM到空闲状态来影响性能。
SM中的一级缓存和共享内存都使用N KB的片上内存,它通过静态划分,但在运行时可以通过下面指令进行动态配置:
cudaFuncSetCahceConfig()在每个核函数的基础上配置了片上内存划分。
cudaFuncCachePreferNone/L1/Shared/Equal。
4.1.2.4 常量内存
常量内存驻留在设备内存中,并在每个SM专用的常量缓存中缓存。常量变量用__constant__修饰符来修饰。
常量变量必须在全局空间中和所有核函数之外进行声明。常量内存是64KB。常量内存实际是静态声明的,并对同一编译单元中所有核函数可见。
核函数只能从常量内存中读取数据。因此,常量内存必须在主机端使用下面的函数来初始化:cudaMemcpyToSymbol。声明的变量存放在设备的全局内存或常量内存中。在大多数情况下,该函数是同步的。
线程束中的所有线程从相同的内存地址中读取数据时,常量内存表现最好。数学公式中的系数就是一个很好的使用常量内存例子。
如果线程束里每个线程都从不同的地址空间读取数据,并且只读一次,那么常量内存中就不是最佳选择,因为每从一个常量内存中读取一次数据,都会广播给线程束里的所有线程。
4.1.2.5 纹理内存
纹理内存驻留在设备内存中,并在每个SM的只读缓存中缓存。
纹理内存是一种通过指定的只读缓存访问的全局内存。
只读缓存包括滤波的支持,它可以将浮点插入作为读过程的一部分来执行。
纹理内存是对二维空间局部性的优化,所以线程束里使用纹理内存访问二维数据的线程可以达到最优性能。
对于不需要访问二维数据和使用滤波硬件的应用程序而言,与全局内存相比,使用纹理内存更慢。
4.1.2.6 全局内存
全局内存是GPU中最大,延迟最高并且经常使用的内存。
global指的是其作用域和生命周期。它的声明可以在任何SM设备上被访问到,并且贯穿应用程序的整个生命周期。
一个全局内存变量可以被静态或动态声明。
你可以使用__device__修饰符在设备代码中静态地声明一个变量。
从多个线程访问全局内存时必须注意。因为线程的执行不能跨线程块同步,不同线程块内的多个线程并发地修改全局内存的同一位置可能会出现问题,这将导致一个未定义的程序行为。
全局内存常驻于设备内存,可通过32字节、64字节、128字节的内存事务进行访问。这些内存事务必须自然对齐。也就是说,首地址必须是32字节、64字节或128字节的倍数。
优化内存事务对于获得最优性能来说是至关重要的。
当一个线程束执行内存加载/存储时,需要满足的传输数量通常取决于两个因素;
1. 跨线程的内存地址分布;
2. 每个事务内存地址的对齐方式;
在一般情况下,用来满足内存请求的事务越多,未使用的字节被传输回的可能性就越高,这就造成了数据吞吐率的降低。对于一个给定的线程束内存请求,事务数量和数据吞吐率是由设备的计算能力来确定的。
4.1.2.7 GPU缓存
GPU缓存是不可编程的内存,一共有四种:
1. L1缓存;
2. L2缓存;
3. 常量缓存;
4. 纹理缓存;
每个SM都有1个L1缓存,所有的SM共享1个L2缓存。L1和L2缓存都被用来存储本地内存和全局内存的数据,也包括寄存器溢出的部分。
在CPU上,内存的加载和存储都可以被缓存。但是,在GPU上只有内存加载操作可以被缓存,内存存储操作不能被缓存。
每个SM也有一个只读常量缓存和纹理缓存,它们用于设备内存中提高来自于各自内存空间内的读取性能。
4.1.2.8 CUDA变量声明总结
修饰符 | 变量名称 | 存储器 | 作用域 | 生命周期 |
float var | 寄存器 | 线程 | 线程 | |
float var[100] | 本地 | 线程 | 线程 | |
__shared__ | float var/[100] | 共享 | 线程块 | 块 |
__device__ | float var/[100] | 全局 | 全局 | 应用程序 |
_-constant__ | float var/[100] | 常量 | 全局 | 应用程序 |
各类存储器的主要特征:
存储器 | 片上/片外 | 缓存 | 存取 | 范围 | 生命周期 |
寄存器 | 片上 | No | R/W | 一个线程 | 线程 |
本地 | 片外 | No | R/W | 一个线程 | 线程 |
共享 | 片上 | No | R/W | 块内所有线程 | 块 |
全局 | 片外 | No | R/W | 所有线程+主机 | 主机配置 |
常量 | 片外 | Yes | R | 所有线程+主机 | 主机配置 |
纹理 | 片外 | Yes | R | 所有线程+主机 | 主机配置 |
4.1.2.9 静态全局内存
__device__ float devData;
int main()
{
float value = 3.4;
cudaMemcpyToSymbol(devData, &value, sizeof(float));
// ... 处理devDatakernel
cudaMemcpyFromSymbol(&value, devData, sizeof(float));
return 0;
}
尽管主机和设备的代码存储在同一个文件中,他们的执行却是完全不同的。即使在同一文件内可见,主机代码也不能直接访问设备变量。类似地,设备代码也不能直接访问主机变量。
你可能认为主机代码可以使用如下代码访问设备的全局变量;cudaMemcpyToSymbol()。是的,但注意:
cudaMemcpyToSymbol函数是存在CUDA运行时API中,可以偷偷使用使用GPU硬件来执行访问;
在这里,devData作为一个标识符,并不是设备全局内存的变量地址;
在核函数中,devData被当做全局内存中的一个变量。
cudaMemcpy函数不能使用如下的变量地址传递数据给devData:
cudaMemcpy(&dataDev, &value, sizeof(float), cudaMemcpyHostToDevice)。
你不能在主机端的设备变量使用&运算符,因为他只是一个在GPU上表示物理位置符号。但是,你可以显式地使用cudaGetSymbolAddress()调用来获得一个全局变量的地址。
float* dptr = nullptr;
cudaGetSymbolAddress((void**)&dptr, devData);
cudaMemcpy(dptr, &value, sizeof(float), cudaMemcpyHostToDevice);
有一个例外,直接直接从主机引用GPU内存:CUDA固定内存。主机代码和设备代码都可以通过简答的指针引用直接访问固定内存。
文件作用域中的变量:可见性和可访问性。一般情况下,设备核函数不能访问主机变量,并且主机函数也不能访问设备变量,即使这些变量在同一文件下作用域内声明。
CUDA运行时API能够访问主机和设备变量,但是这取决你给正确的函数是否提供了正确的参数。
4.2 内存管理
现在,工作重点在于如何使用CUDA函数来显示地管理内存和数据移动。
1. 分配和释放设备内存;
2. 在主机和设备之间传递数据;
4.2.1 内存分配和释放
可以在主机侧使用下列函数分配全局内存:
cudaMalloc()在设备上分配了count字节的全局内存。所分配的内存支持任何变量类型。如果cudaMalloc函数执行失败,则放回cudaErrorMemoryAllocation。
在已分配的全局内存中的值不会被清除,需要用从主机上传输的数据来填补所分配的全局内存,或用下列函数进行初始化。cudaMemset()。
一旦应用程序不再使用已分配的全局内存,那么可以用cudaFree函数来释放该内存空间。
设备内存的分配和释放操作成本高,所以应用程序应重利用设备内存。
4.2.2 内存传输
cudaMemcpy()函数从主机向设备传输数据。如果指针和指定方向不一致,那么cudaMemcpy的行为就是未定义的。
size_t nbytes = 1 << 22 * sizeof(float);
float* h_a = (float*)malloc(sizeof(nbytes))
float* d_a;
cudaMalloc((void**)&d_a, nbytes);
cudaMemcpy(d_a, h_a, nbytes, cudaMemcpyHostToDevice);
free(h_a);
cudaFree(d_a);
CUDA编程的一个基本原则应是尽可能减少主机和设备之间的传输。
4.2.3 固定内存
分配的主机内存默认是可分页的,它的意思也就是因页面错误导致的操作,该操作按照操作系统的要求将主机虚拟内存上的数据移动到不同的物理位置。
GPU不能在可分页主机内存上安全地访问数据,因为当主机操作系统在物理位置上移动该数据时,它无法控制。
当从可分页的主机内存传输数据到设备内存时,CUDA驱动程序首先分配临时页面锁定的或固定的主机内存,将主机源数据复制到固定内存中,然后从固定内存传输数据到设备内存。
CUDA运行时允许你使用cudaMallocHost()函数直接分配固定主机内存。这些内存是页面锁定的且对设备来说是可访问的。由于固定内存能被设备直接访问,所以它能用比可分页内存高的多的带宽进行读写。
然而,分配过多的固定内存可能会降低主机系统的性能,因为它减少了用于虚拟内存数据可分页的内存的数量,其中可分页内存对主机系统是可用的。
固定主机内存必须通过cudaFreeHost()函数来释放。
主机与设备间的内存传输:
1. 与可分页内存相比,固定内存的分配和释放的成本更高,但是它为大规模数据传输提供了更高的传输吞吐量;
2. 相对于可分页内存,使用固定内存获得的加速取决于设备计算能力;
3. 将许多小的传输批处理为一个更大的传输能提供性能,因为减少了单位传输消耗;
4. 主机和设备之间的数据传输有时可以和内核执行重叠。
4.2.4 零拷贝内存
通常来说,主机不能直接访问设备变量,同时设备也不能直接访问主机变量。当有一个另外,零拷贝内存。主机和设备都能访问零拷贝内存。
GPU线程可以直接访问零拷贝内存。在CUDA核函数中使用零拷贝内存有以下优势:
1. 当设备内存不足时可利用主机内存;
2. 避免主机和设备间显式数据传输;
3. 提高PCIe传输率;
当使用零拷贝内存来共享主机和设备间的数据时,你必须同步主机和设备间的内存访问,同时更改主机和设备的零拷贝内存中的数据将导致不可预知的后果。
零拷贝内存是固定内存(不可分页),该内存映射到设备地址空间中。你可以通过cudaHostAlloc()函数创建一个到固定内存的映射。该内存是页面锁定的且设备可访问的。需要用cudaFreeHost()函数释放。其中的flags参数可以对已分配内存的特殊属性进一步进行配置;
1. cudaHostAllocDefault:使得cudaHostAlloc函数行为与cudaMallocHost函数一致。
2. cudaHostAllocPortable:可以返回能被所有CUDA上下文使用的固定内存,而不仅是执行内存分配的哪一个。
3. cudaHostAllocWriteCombined返回写结合内存,该内存可以在某些系统配置上通过PCIe总线上更快地传输,但是它在大多数主机上不能有效地读取。因此,写结合内存对缓冲区来说一个很好的选择,该内存通过设备使用映射的固定内存或主机到设备的传输。
4. cudaHostAllocMapped可实现主机写入和设备读取被映射到设备地址空间中的主机内存。
可以使用cudaHostGetDevicePointer()获取映射到固定内存的设备指针。该指针可以在设备被引用以访问映射得到的固定的主机内存。
当进行频繁的读写操作时,使用零拷贝内存作为设备内存的补充将显著降低性能。因为每一次映射到内存的传输必须经过PCIe总线。与全局内存相比,延迟也显著增加。
从结果上看,如果你想共享主机和设备端的少量内存,零拷贝内存可能会是一个不错的选择,因为它简化了编程并且有很好的性能。
对于由PCIe总线连接的离散GPU上的更大数据集来说,零拷贝内存不是一个好的选择,它会导致性能显著下降。
零拷贝内存有两种常见的异构计算系统架构:集成架构和离散架构。
1. 在集成架构中,CPU和GPU集成在同一个芯片上,并且物理地址上共享内存。此时由于无需在PCIe总线上备份,所以零拷贝内存在性能和可编程性方面可能更佳。
2. 对于通过PCIe总线将设备连接到主机的离散系统中,零拷贝内存只在特殊情况下有优势。因为映射的固定内存在主机和设备之间是共享的,你必须同步内存访问来避免任何的潜在的数据冲突,这种数据冲突一般是由多线程异步访问相同的内存而引起的。
注意不要过多的使用零拷贝内存,因为延迟较高。
4.2.5 统一虚拟寻址
计算能力2.0及以上的设备支持统一虚拟寻址UVA。在CUDA4.0中被引入。有了UVA,主机内存和设备内存可由共享同一个虚拟地址空间,功能如下:
1. 由指针指向的内存空间对应应用程序来说是透明的。
2. 有cudaHostAlloc分配的固定主机内存具有相同的主机和设备指针。因此,可以将返回的指针直接传递给核函数。
3. 无需获取设备指针或管理物理数据完全相同的两个指针。
cudaHostAlloc((void**)&h_A, nBytesm,cudaHostAllocMapped);
initialData(h_A, nElem);
sumArraysZero<<<grid, blocks>>>(h_A, nElem);
注意,从cudaHostAlloc函数返回的指针直接传递给核函数。
4.2.6 统一内存寻址
在CUDA6.0中,引入了“统一内存寻址”这一新特性,它用于简化CUDA编程模型中的内存管理。统一内存中创建了一个托管内存池,内存池中已分配的空间可以用相同的内存地址(指针)在CPU和GPU上进行访问。底层系统在同一内存空间中自动在主机和设备之间传输数据。这种传输数据对于应用程序是透明的,这大大简化了代码。
统一内存寻址依赖于UVA的支持,但它们是完全不同的技术。UVA为系统中的所有处理器提供了一个单一的虚拟内存地址。但是,UVA不会自动地将数据从一个物理位置转移到另一个位置,这是统一内存寻址的特有功能。
统一内存寻址提供了一个“单指针到数据”模型,在概念上它类似于零拷贝内存。但是零拷贝内存在主机内存中进行分配,因此,由于受到PCIe总线上访问零拷贝内存的影响,核函数的性能将具有较高的延迟。另一方面,统一内存寻址将内存和执行空间分离,因此可以根据需要将数据透明地传输到主机或设备上,以提升局部性和性能。
托管内存指的是由底层系统自动分配的统一内存,与设备的分配内存可以互操作。
托管内存可以被静态地分配也可以被动态地分配,通过添加__managed__注释,静态地声明一个设备变量为托管变量。但这个操作只能在文件范围内和全局范围内进行。该变量可以从主机或设备代码中直接地被引用。
__device__ __managed__ int y;
还可以使用cudaMallocManaged函数动态地分配托管内存。
使用托管内存的程序可以利用自动数据传输和重复指针消除功能。
在CUDA6.0中,设备代码不能调用cudaMallocManaged函数。所有托管内存必须在主机端动态声明或者在全局范围内静态地声明。
4.3 内存访问模式
最大限度地利用全局内存带宽是调控核函数性能的基本。
CUDA执行模型的显著特征之一就是指令必须以线程束为单位进行发布和执行。存储操作也是同样。在执行内存指令时,线程束中的每个线程都提供了一个正在加载或存储的内存地址。在线程束内的32个线程中,每个线程都提出了一个包含请求地址的单一内存访问请求,它有一个或多个设备内存传输提供服务。
4.3.1 对齐和合并访问
所有的应用程序数据最初都存储在DRAM上,即物理设备内存中,核函数的内存请求通常是在DRAM设备和片上内存空间以128字节或32字节内存事务来实现的。
所有对全局内存的访问都会通过二级缓存,也有许多访问会通过一级缓存,这取决于访问类型和GPU架构。如果这两级缓存都被用到,那么内存访问是由一个128字节的内存事务实现的。如果只使用了二级缓存,那么内存访问是由一个32字节的内存事务实现的。可以在编译时采取启用或禁用一级缓存。
一行L1缓存线是128字节,它映射到设备内存中一个128字节的对齐段。
在优化应用程序时,需要注意设备内存访问的两个特性:
1. 对齐内存访问;
2. 合并内存访问;
当设备内存事务的第一个地址是用于事务服务的缓存粒度的偶数倍时(32字节的二级缓存或128字节的一级缓存),就会出现对齐内存访问。运行非对齐的加载会造成带宽浪费。
当一个线程束中全部的32个线程访问一个连续的内存块时,就会出现合并内存访问。
对齐合并内存访问的理想状态是线程束从对齐内存地址开始访问同一个连续的内存块。
一般来说,需要优化内存事务效率:用最少的事务次数来满足最多的内存请求。
4.3.2 全局内存读取
在SM中,数据通过以下3种路径进行传输,具体使用何种方式取决于用了那种类型的设备内存:
1. L1和L2缓存;
2. 常量内存;
3. 只读缓存;
L1和L2缓存是默认路径。想要通过其他两种路径传递数据需要应用程序显式地说明。但是想要提升性能还要取决于使用的访问模式。
全局内存加载操作是否会通过L1缓存取决于两个因素:设备的计算能力和编译器选项;
默认情况下,在Fermi设备上对于全局内存加载可以用L1缓存,在K40及以上的GPU中禁用。以下标志通知编译器禁用一级缓存:-Xptxas -dlcm=cg
如果L1缓存被禁用,所有对全局内存的加载请求将直接进入L2缓存;如果L2缓存缺失,则由DRAM完成请求。每一次内存事务可由一个、两个或四个部分执行,每个部分32个字节。使用下列标识符直接启用L1缓存:-Xptxas -dlcm=ca
在Kepler GPU中,L1缓存不用来缓存全局内存加载,而是专门用于缓存寄存器溢出到本地内存中的数据。内存加载访问模式:
1. 缓存加载(启用L1缓存);
2. 没有缓存加载(禁用L1缓存);
特点:
1. 有缓存和没有缓存: 如果启用一级缓存,则内存加载被缓存;
2. 对齐与非对齐: 如果内存访问的第一个地址是32字节的倍数,则对齐加载;
3. 合并与非合并: 如果线程束访问同一个连续的数据块,则加载合并;
4.3.2.1 缓存加载
对齐与合并内存访问:
访问是对齐的,引用的地址不是连续的线程ID。仅当每个线程请求在128字节范围内有4个不同的字节时,这个事务中才没有未被使用的数据。
非对齐访问,合并访问;当启用一级缓存时,由SM执行的物理地址加载操作必须在128字节的界线上对齐,所以要求要有两个128字节的事务来执行这段内存加载操作。总线利用率50%。
线程束中所有线程请求相同的地址。因为被引用的字节落在一个缓存行范围内,所以只需请求一个内存事务,但总线利用率非常低。4/128 = 3.125%
线程束中线程请求分散在全局内存中的32个4字节地址。尽管线程束请求的字节数总数仅为128个字节,但地址要占用N个缓存行。
CPU L1缓存和GPU L1缓存之间的差异:CPU L1缓存优化了时间和空间局部性。GPU L1缓存是专为空间局部性而不是为了时间局部性设计的。频繁访问一个L1缓存的内存位置不会增加数据留在缓存中的概率。
4.3.2.2 没有缓存的加载
没有缓存的加载不经过L1缓存,它在内存段的粒度上(32个字节)而非缓存池的粒度(128字节)执行。这是更细粒度的加载,可以为非对齐或非合并的内存访问带来更好的总线利用率。
4.3.2.3 非对齐读取的示例
因为访问模式往往是由应用程序实现的一个算法来决定的,所以对于某些应用程序来说合并内存加载是一个挑战。
使用某些偏移量会导致内存访问是非对齐的。导致运行时间的加长。通过观察全局加载效率为指标的结果,可以验证这些非对齐访问就是性能损失的原因:
全局加载效率 = 请求的全局内存加载吞吐量 / 所需的全局内存加载吞吐量
可以使用nvprof获取gld_efficiency指标。对于非对齐读取的情况,全局加载效率最少减半,这意味着全局内存加载吞吐量加倍。
也可以用全局加载事务指标来直接验证gld_transactions。
对于非对齐情况,禁用L1缓存使加载效率得到了提高。因为加载粒度从128字节下降为32字节。
4.3.2.4 只读缓存
只读缓存最初是给纹理内存加载使用的,对于计算能力3.5及以上的GPU来说,只读缓存也支持使用全局内存加载代替L1缓存。只读缓存的加载粒度是32个字节。通常,对于分散读取来说,这些更细粒度的加载要优于L1缓存。
有两种方式可以指导内存通过只读缓存进行读取:
1. 使用函数__lgd;
2. 在间接引用的指针上使用修饰符;
out[idx] = __lgd(&in[idx]);
也可以将常量__restrict__修饰符应用到指针上。这些修饰符帮助nvcc编译器识别无别名指针(即专门用来访问特定数组的指针)。
__global__ void copyKernel(int* restrict__ out)
{...}
4.3.3 全局内存写入
内存存储操作相对简单。L1缓存不能用在Fermi和Kepler GPU上进行存储操作,在发送到设备内存之前存储操作只通过L2缓存。存储操作在32字节段的粒度上执行。内存事务可以同时被分为一段,二段或四段。
4.3.4 结构体数组和数组结构体
利用AoS,存储的是空间上相邻的数据,在CPU上会有良好的缓存局部性。
利用SoA,不仅能将相邻的数据点紧密存储起来,也能将跨数组的独立数据点存储起来。
利用SoA模式存储数据充分利用了GPU带宽。由于没有相同字段元素的交叉存取,GPU上的SoA布局提供了合并内存访问,并且可以对全局内存实现更高效的利用。
在许多并行编程范式中,尤其是SIMD型范式,更倾向于使用SoA。在CUDA C编程中也普遍更倾向于使用SoA,因为数据元素是为全局内存的有效合并访问而预先准备好的,而被相同内存操作引用的的同字段数据元素在存储时也是相邻的。
对于AoS数据布局,加载请求和内存存储请求是重复的。因此,请求加载和存储的50%带宽是未使用的。
4.3.5 性能调整
优化设备内存带宽利用率的两个目标:
1. 对齐及合并内存访问,以减少带宽的浪费;
2. 足够数量的并发内存操作,以隐藏内存延迟;
第三章讨论优化指令吞吐量的核函数,实现并发内存访问的最大化是通过一下方式获得的:
1. 增加每个线程中执行独立内存操作的数量;
2. 对核函数启动的执行配置进行试验,以充分体现每个SM的并行性;
4.3.5.1 展开技术
使每个线程都执行多个独立的内存操作,于是可以调用更多的并发内存访问。
但是,展开技术并不影响执行内存操作的数量(只影响并发执行的数量)。
4.3.5.2 增大并行性
为了充分体现并行性,你应该用一个核函数启动的网格和线程块大小进行测试。此时需要注意到两个硬件限制,每个SM最多有多少个并发线程块,以及每个SM最多有多少并发线程束。
影响设备内存操作性能的因素主要有两个:
1. 有效利用设备DRAM和SM片上内存之间的字节移动:为了避免设备内存带宽的浪费,内存访问模式应是对齐和合并的;
2. 最大化当前存储器操作数:1) 展开,每个线程产生更多的独立内存访问。 2) 修改核函数启动的执行配置来使每个SM有更多的并行性。
4.4 核函数可达到的带宽
在分析核函数性能时候,需要注意内存延迟,即完成一次独立内存请求的时间。
内存带宽,即SM访问设备内存的速度,它以每单位时间内的字节数进行测量。
在上一节中,你已经尝试使用两种方法来改进核函数的性能:
1. 通过最大化并行执行线程束的数量来隐藏内存延迟,通过维持更多正在执行的内存访问来达到更好的总线利用率。
2. 通过适当的对齐和合并内存访问来最大化内存带宽的效率。
4.4.1 内存带宽
大多数核函数对内存带宽十分敏感,也就是说它们有内存带宽限制。因此需要特别注意全局内存中数据的布局,以及线程束访问该数据的方式。
一般有如下两个类型的带宽;
1. 理论带宽:当前硬件可以实现的绝对最大带宽。
2. 有效带宽:核函数实际达到的带宽,它是测量带宽。
4.4.2 矩阵转置
观察输入与输出布局,会发现:
读操作:通过原矩阵的行进行访问,结果为合并访问;
写操作:通过转置矩阵的列进行访问,结果为交叉访问;
4.4.2.1 为转置核函数设置性能的上限和下限
创建两个拷贝核函数来粗略计算所有转置核函数性能的上下限:
通过加载和存储行来拷贝矩阵(上限)。这样将模拟执行相同数量的内存操作作为转置,但是只能使用合并访问;
通过加载和存储列来拷贝矩阵(下限)。这样将模拟执行相同数量的内存操作作为转置,但是只能使用合并访问;
__global__ void copyRow(float* out, float* in, const int nx, const int ny)
{
unsigned int ix = blockDim.x * blockIdx.x + threadIdx.x;
unsigned int iy = blockDim.y * blockIdx.y + threadIdx.y;
if (ix < nx && iy < ny)
out[iy * nx + ix] = in[iy * nx + ix];
}
__global__ void copyCol(float* out, float* in, const int nx, const int ny)
{
unsigned int ix = blockDim.x * blockIdx.x + threadIdx.x;
unsigned int iy = blockDim.y * blockIdx.y + threadIdx.y;
if (ix < nx && iy < ny)
out[ix * ny + iy] = in[ix * ny + iy];
}
4.4.2.2 朴素转置:读取行与读取列
__global__ void transposeNaiveRow(float* out, float* in, const int nx, const int ny)
{
unsigned int ix = blockDim.x * blockIdx.x + threadIdx.x;
unsigned int iy = blockDim.y * blockIdx.y + threadIdx.y;
if (ix < nx && iy < ny)
out[ix * ny + iy] = in[iy * nx + ix];
}
__global__ void transposeNaiveCol(float* out, float* in, const int nx, const int ny)
{
unsigned int ix = blockDim.x * blockIdx.x + threadIdx.x;
unsigned int iy = blockDim.y * blockIdx.y + threadIdx.y;
if (ix < nx && iy < ny)
out[iy * nx + ix] = in[ix * ny + iy];
}
NaiveCol性能比NaiveRow性能更好的原因之一可能是缓存中执行了交叉读取。即使通过某一方式读入L1缓存中的数据没有都被这次访问使用到,这些数据仍然留在缓存中,在以后的访问过程中可能发生缓存命中。
在禁用L1缓存后,结果表明缓存交叉读取能够获得最高的加载吞吐量。
4.4.2.3 展开转置:读取行和读取列
展开的目的是为每个线程分配更独立的任务,从而最大化当前内存请求。
以下是基于展开因子为4的基于行的实现。
__global__ void transposeUnroll4Row(float* out, float* in, const int nx, const int ny)
{
unsigned int ix = blockDim.x * blockIdx.x * 4 + threadIdx.x;
unsigned int iy = blockDim.y * blockIdx.y + threadIdx.y;
unsigned int ti = iy * nx + ix;
unsigned int to = ix * ny + iy;
if (ix + blockDim.x * 3 < nx && iy < ny)
{
out[to] = in[ti];
out[to + ny * blockDim.x] = in[ti + blockDim.x];
out[to + ny * blockDim.x * 2] = in[ti + blockDim.x * 2];
out[to + ny * blockDim.x * 3] = in[ti + blockDim.x * 3];
}
}
4.4.2.4 对角转置:读取行和读取列
编程模型抽象可能用一维或二维布局来表示该网格,但是从硬件的角度来看,所有块都是一维的。
当启用一个核函数时,线程块被分配给SM的顺序由块ID来确定。
int bid = blockIdx.y * gridDim.x + blockIdx.x;
由于线程块完成的速度和顺序是不确定,随着内核进程的执行,起初通过块ID会变成不太连续。
尽管无法直接调控线程块的顺序,但可以灵活使用块坐标blockIdx.x和blockIdx.y。
对角坐标系用于确定一维线程块的ID,但对于数据访问,仍需使用笛卡尔坐标系。
当使用对角坐标系表示块ID时,需要将对角坐标系映射到笛卡尔坐标系中。对于一个方阵来说,这个映射可以通过以下的方程式计算;
block_x = (blockIdx.x + blockIdx.y) % gridDim.x;
block_y = blockIdx.x;
__global__ void transposeDiagonalRow(float* out, float* in, const int nx, const int ny)
{
unsigned int blk_y = blockIdx.x;
unsigned int blk_x = (blockIdx.x + blockIdx.y) % gridDim.x;
unsigned int ix = blockDim.x * blk_x + threadIdx.x;
unsigned int iy = blockDim.y * blk_y + threadIdx.y;
if (ix < nx && iy < ny)
out[ix * ny + iy] = in[iy * nx + ix];
}
使用对角坐标系来修改线程块的执行顺序,这使基于行的核函数性能得到了大大提升。对角核函数的实现可以通过展开块得到更大的提升,但是这种实现不像使用笛卡尔坐标系的核函数那么简单直接。
这种性能提升的原因与DRAM的并行访问有关。当使用笛卡尔坐标将线程块映射到数据块时,全局内存可能无法均匀地被分配到整个DRAM从分区中,这时候就可能发生分区冲突现象。发生分区冲突时,内存请求在某些分区中排队等候,而另一些分区一直未被调用。因为对角坐标映射造成了从线程块到待处理数据块的非线性映射,所以交叉访问不太可能会落入到一个独立的分区中,并且会带来性能的提升。
4.4.2.5 使用瘦块来增加并行性
增加并行性最简单的方式是调整块的大小。通过增加存储在线程块中连续元素的数量,瘦块可以提高存储操作的效率。(8, 32),而不是(32, 8)
4.5 使用同一内存的矩阵加法
为了简化主机和设备内存空间的管理,提高这个CUDA程序的可读性和易维护性,可以使用统一内存将以下解决方案添加到矩阵加法的主函数中:
1. 用托管内存分配来替换主机和设备内存的分配,以消除重复指针;
2. 删除所有显示的内存副本;
// 声明和分配3个托管数组
float* A, * B, * gpuRef;
cudaMallocManaged((void**)&A, nbytes);
cudaMallocManaged((void**)&B, nbytes);
cudaMallocManaged((void**)&gpuRef, nbytes);
// 使用指向托管内存的指针来初始化主机上的输入数据
initial(A, nxy);
initial(B, nxy);
// 通过指向托管内存的指针调用矩阵加法核函数
sumMatrixGPU<<<grid, block>>>(A, B, gpuRef, nx, ny);
cudaDeviceSynchronize();
如果是在一个多GPU设备的系统上进行测试,托管应用需要附加步骤。因为托管内存分配对系统中的所有设备是可见的,所有可以限制哪一个设备对应用程序是可见的,这样托管内存只分配在一个设备上。设置环境变量CUDA_VISIBLE_DEVICES。
使用托管内存造成CPU初始化时间更长。
通过以下nvprof标志启用统一内存相关指标:nvprof --unified-memory-profiling per-process-device
当CPU需要访问当前驻留在GPU中的托管内存时,统一内存使用CPU页面故障来触发设备到主机的数据传输。
4.6 总结
对程序员直接可用的GPU内存够层次结构,这使得移动和布局提供了更多的控制,优化了性能并得到了更高的峰值性能。
两种提高带宽利用率的方法:
1. 最大化当前并发内存访问的次数;
2. 最大化在总线上的全局内存和SM片上内存之间移动字节的利用率;
为了保持足够多的正在执行的内存操作,可以使用展开技术在每个线程中创建更多的独立内存请求,或调整网格和线程块的执行配置来体现充分的SM并行性。
为了避免在设备内存和片上内存之间有未被使用数据移动,应该努力实现理想的访问模式:对齐和合并内存访问。
改进合并访问的重点在于线程束中的内存访问模式。另一方面,消除分区冲突的重点则在于所有活跃的线程束的访问模式。对角坐标映射是一种通过调整块执行顺序来避免分区冲突的方法。
通过消除重复指针已经在主机和设备之间显式地传输数据的需要,统一内存大大简化了CUDA编程。