CUDA入门:硬件模型入门与性能优化

目录

硬件模型:

线程模型:

内存模型:

SIMT架构:

Warp(并行线程组):

基本概念:

warp的执行方式:

SIMT与SIMD的区别:

Volta架构:

注意:

性能优化:

核心原则:

实现最大化利用率:

最大化存储吞吐量:

最大化指令吞吐量:

最小化内存抖动:

学习资料:


前记:呜呜呜,最近事情太多了,看了都没写,寄!-----------------------------------博主:mx

硬件模型:

CUDA入门:硬件模型入门与性能优化_第1张图片

如上图所示,CUDA程序一般会创建一些线程块(Block),线程块会被调度到空闲的流处理器簇(SM)上去。当线程块执行完毕后,线程块会退出SM,释放出SM的资源,以供其他待执行线程块调度进去。

因此,无论是只有2个SM的GPU,还是有4个SM的GPU,这些线程块都会被调度执行,只不过是执行的时间有长有短。因此,同样的程序,可以在具有不同SM数量上的GPU运行。

线程模型:

CUDA入门:硬件模型入门与性能优化_第2张图片

内存模型:

CUDA入门:硬件模型入门与性能优化_第3张图片

  • 最上方是主机端内存(host memory),指的就是我们常说的内存。一般主机端内存通过PCI-E总线与设备端内存交换数据。数据交换的速度等于PCI-E总线的速度。
  • 全局内存(global memory) 、常量内存(constant memory)、纹理内存(texture memory)、本地内存(local memory)。都位于GPU板上,但不在片内。因此速度相对片内内存较慢。
    常量内存和纹理内存对于GPU来说是只读的。
  • GPU上有 L2 cache和 L1 cahce。其中L2 cache为所有流处理器簇(SM)共享,而L1 cache为每个SM内部共享。这里的cache和CPU的cache一样,程序员无法对cache显式操纵。
  • 纹理缓存和常量缓存在SM内部共享,在早期1.x计算能力的时代,这两种缓存是片上唯一的缓存,十分宝贵。而当Fermi架构出现后,普通的全局内存也具有了缓存,因此就不那么突出了。
  • 共享内存(shared memory, SMEM) 具有和L1缓存同样的速度,且可以被程序员显式操纵,因此经常被用作存放一些需要反复使用的数据。共享内存只能在SM内共享,且对于CUDA编程模型来说,即使线程块被调度到了同一个SM内也无法互相访问。
  • GPU的寄存器(registers) 和CPU不一样,其空间非常巨大,以至于可以为每一个线程分配一块独立的寄存器空间。因此,不像CPU那样切换进程时需要保存上下文,GPU只需要修改一下寄存器空间的指针即可继续运行。所以巨大的寄存器空间,使得GPU上线程切换成为了一个几乎无消耗的操作。

CUDA入门:硬件模型入门与性能优化_第4张图片

  • 寄存器和本地内存绑定到了每个线程,其他线程无法访问。
  • 同一个线程块内的线程,可以访问同一块共享内存。注意,即使两个线程块被调度到了同一个SM上,他们的共享内存也是隔离开的,不能互相访问。
  • 网格中的所有线程都可以自由读写全局内存。
  • 常量内存和纹理内存只能被CPU端修改,GPU内的线程只能读取数据。

SIMT架构:

Warp(并行线程组):

基本概念:

多处理器以 32 个并行线程组(称为 warp)的形式创建、管理、调度和执行线程。组成 warp 的各个线程一起从同一个程序地址开始,但它们有自己的指令地址计数器和寄存器状态,因此可以自由地分支和独立执行。

当一个多处理器获得多个线程块时,多处理器会将其划分warp,每个warp 都由 warp 调度程序调度以执行。每个warp包含连续的线程,增加线程id,第一个warp包含线程0。

warp的执行方式:

一个 warp 一次执行一条公共指令,当一个 warp 的所有 32 个线程都同意它们的执行路径时,就可以实现完全的效率。如果 warp 的线程执行条件分支的代码时,warp执行所采用的每个分支路径,都会禁用不在该路径上的线程。

多处理器处理的每个 warp 的执行上下文(程序计数器、寄存器等)在 warp 的整个生命周期内都在芯片上维护。因此,从一个执行上下文切换到另一个执行上下文是没有成本的,并且在每个指令发出时,warp 调度程序都会选择一个线程准备好执行其下一条指令(warp 的活动线程)并将指令发布给这些线程。

每个多处理器都有一组 32 位寄存器,这些寄存器在 warp 之间进行分区,以及在线程块之间进行分区的并行数据缓存或共享内存。

对于给定内核,可以在多处理器上一起驻留和处理的块和 warp 的数量取决于内核使用的寄存器和共享内存的数量以及多处理器上可用的寄存器和共享内存的数量。每个多处理器也有最大数量的驻留块和驻留 warp 的最大数量。

CUDA入门:硬件模型入门与性能优化_第5张图片

SIMT与SIMD的区别:

SIMT 体系结构类似于 SIMD(单指令多数据)向量组织,其中单指令控制多个处理元素。一个关键区别是 SIMD 矢量组织向软件公开了 SIMD 宽度,而 SIMT 指令指定单个线程的执行和分支行为。与 SIMD 向量机相比,SIMT 使程序员能够为独立的标量线程编写线程级并行代码,以及为协调线程编写数据并行代码。为了正确起见,程序员基本上可以忽略 SIMT 行为;但是,通过代码很少需要 warp 中的线程发散,可以实现显着的性能改进。在实践中,这类似于传统代码中缓存线的作用:在设计正确性时可以安全地忽略缓存线大小,但在设计峰值性能时必须在代码结构中考虑。另一方面,向量架构需要软件将负载合并到向量中并手动管理分歧

Volta架构:

从 Volta 架构开始,独立线程调度允许线程之间的完全并发,而不管 warp。使用独立线程调度,GPU 维护每个线程的执行状态,包括程序计数器和调用堆栈,并且可以在每个线程的粒度上产生执行,以便更好地利用执行资源或允许一个线程等待数据由他人生产。调度优化器确定如何将来自同一个 warp 的活动线程组合成 SIMT 单元。这保留了与先前 NVIDIA GPU 一样的 SIMT 执行的高吞吐量,但具有更大的灵活性:线程现在可以在 sub-warp 粒度上发散和重新收敛。

注意:

如果一个由 warp 执行的原子指令读取、修改和写入全局内存中多个线程的同一位置,则对该位置的每次读取/修改/写入都会发生并且它们都被序列化,但是它们发生的顺序是不确定的。

性能优化:

核心原则:

性能优化围绕四个基本策略:

  • 最大化并行执行以实现最大利用率;
  • 优化内存使用,实现最大内存吞吐量;
  • 优化指令使用,实现最大指令吞吐量;
  • 尽量减少内存抖动;

实现最大化利用率:

基本原理:

应用程序的结构应该尽可能多地暴露并行性,并有效地将这种并行性映射到系统的各个组件,以使它们大部分时间都处于忙碌状态。

应用程序层次

应用程序应该通过使用异步函数调用和异步并发执行中描述的流来最大化主机、设备和将主机连接到设备的总线之间的并行执行。应该为每个处理器分配它最擅长的工作类型:主机的串行工作负载;设备的并行工作负载。

对于并行工作负载,在算法中由于某些线程需要同步以相互共享数据而破坏并行性的点,有两种情况:①这些线程属于同一个块,在这种情况下,它们应该使用 __syncthreads () 并在同一个内核调用中通过共享内存共享数据,或者它们属于不同的块,在这种情况下,它们必须使用两个单独的内核调用通过全局内存共享数据,一个用于写入,一个用于从全局内存中读取。②第二种情况不太理想,因为它增加了额外内核调用和全局内存流量的开销。因此,应该通过将算法映射到 CUDA 编程模型以使需要线程间通信的计算尽可能在单个线程块内执行,从而最大限度地减少它的发生。

设备层次:

多个内核可以在一个设备上并发执行,因此也可以通过使用流来启用足够多的内核来实现最大利用率。

多处理器层次:

GPU 多处理器主要依靠线程级并行性来最大限度地利用其功能单元。因此,利用率与常驻 warp 的数量直接相关。在每个指令发出时,warp 调度程序都会选择一条准备好执行的指令。该指令可以是同一 warp 的另一条独立指令,利用指令级并行性,或者更常见的是另一个 warp 的指令,利用线程级并行性。如果选择了准备执行指令,则将其发布到 warp 的活动线程。一个 warp 准备好执行其下一条指令所需的时钟周期数称为延迟,并且当所有 warp 调度程序在该延迟期间的每个时钟周期总是有一些指令要为某个 warp 发出一些指令时,就可以实现充分利用,或者换句话说,当延迟完全“隐藏”时。隐藏 L 个时钟周期延迟所需的指令数量取决于这些指令各自的吞吐量(有关各种算术指令的吞吐量,请参见算术指令)。如果我们假设指令具有最大吞吐量,它等于:

  • 4L 用于计算能力 5.x、6.1、6.2、7.x 和 8.x 的设备,因为对于这些设备,多处理器在一个时钟周期内为每个 warp 发出一条指令,一次四个 warp,如计算能力中所述。
  • 2L 用于计算能力 6.0 的设备,因为对于这些设备,每个周期发出的两条指令是两条不同warp的一条指令。
  • 8L 用于计算能力 3.x 的设备,因为对于这些设备,每个周期发出的八条指令是四对,用于四个不同的warp,每对都用于相同的 warp。

warp 未准备好执行其下一条指令的最常见原因是该指令的输入操作数尚不可用。

延迟:

如果所有输入操作数都是寄存器,则延迟是由寄存器依赖性引起的,即,一些输入操作数是由一些尚未完成的先前指令写入的。在这种情况下,延迟等于前一条指令的执行时间,warp 调度程序必须在此期间调度其他 warp 的指令。执行时间因指令而异。

如果某些输入操作数驻留在片外存储器中,则延迟要高得多:通常为数百个时钟周期。在如此高的延迟期间保持 warp 调度程序繁忙所需的 warp 数量取决于内核代码及其指令级并行度。一般来说,如果没有片外存储器操作数的指令(即大部分时间是算术指令)与具有片外存储器操作数的指令数量之比较低(这个比例通常是称为程序的算术强度)。

如果某些输入操作数驻留在片外存储器中,则延迟要高得多:通常为数百个时钟周期。在如此高的延迟期间保持 warp 调度程序繁忙所需的 warp 数量取决于内核代码及其指令级并行度。

warp 未准备好执行其下一条指令的另一个原因是它正在某个内存栅栏(内存栅栏函数)或同步点(同步函数)处等待。随着越来越多的 warp 等待同一块中的其他 warp 在同步点之前完成指令的执行,同步点可以强制多处理器空闲。在这种情况下,每个多处理器拥有多个常驻块有助于减少空闲,因为来自不同块的 warp 不需要在同步点相互等待。

一个块所需的共享内存总量等于静态分配的共享内存量和动态分配的共享内存量之和。

内核使用的寄存器数量会对驻留 warp 的数量产生重大影响。

例如,对于计算能力为 6.x 的设备,如果内核使用 64 个寄存器并且每个块有 512 个线程并且需要很少的共享内存,那么两个块(即 32 个 warp)可以驻留在多处理器上,因为它们需要 2x512x64 个寄存器,它与多处理器上可用的寄存器数量完全匹配。

但是一旦内核多使用一个寄存器,就只能驻留一个块(即 16 个 warp),因为两个块需要 2x512x65 个寄存器,这比多处理器上可用的寄存器多。

因此,编译器会尽量减少寄存器的使用,同时保持寄存器溢出和最少的指令数量。可以使用 maxrregcount 编译器选项或启动边界来控制寄存器的使用,如启动边界中所述。

占用率计算:
cudaOccupancyMaxActiveBlocksPerMultiprocessor,可以根据内核的块大小和共享内存使用情况提供占用预测。此函数根据每个多处理器的并发线程块数报告占用情况。

cudaOccupancyMaxPotentialBlockSize 和 cudaOccupancyMaxPotentialBlockSizeVariableSMem,启发式地计算实现最大多处理器级占用率的执行配置。

最大化存储吞吐量:

一些基本概念:

最大化应用程序的整体内存吞吐量的第一步是最小化低带宽的数据传输。

这意味着最大限度地减少主机和设备之间的数据传输,如主机和设备之间的数据传输中所述,因为它们的带宽比全局内存和设备之间的数据传输低得多。

这也意味着通过最大化片上内存的使用来最小化全局内存和设备之间的数据传输:共享内存和缓存(即计算能力 2.x 及更高版本的设备上可用的 L1 缓存和 L2 缓存、纹理缓存和常量缓存 适用于所有设备)。

共享内存相当于用户管理的缓存:应用程序显式分配和访问它。如 CUDA Runtime 所示,典型的编程模式是将来自设备内存的数据暂存到共享内存中;换句话说,拥有一个块的每个线程:

  • 将数据从设备内存加载到共享内存,
  • 与块的所有其他线程同步,以便每个线程可以安全地读取由不同线程填充的共享内存位置, 处理共享内存中的数据,
  • 如有必要,再次同步以确保共享内存已使用结果更新,
  • 将结果写回设备内存。

在具有前端总线的系统上,主机和设备之间的数据传输的更高性能是通过使用页锁定主机内存来实现的

全局内存:

全局内存指令支持读取或写入大小等于 1、2、4、8 或 16 字节的字。当且仅当数据类型的大小为 1、2、4、8 或 16 字节并且数据为对齐。对于结构,大小和对齐要求可以由编译器使用对齐说明符 __align__(8) 或 __align__(16) 强制执行

二维数组:

一个常见的全局内存访问模式是当索引 (tx,ty) 的每个线程使用以下地址访问一个宽度为 width 的二维数组的一个元素时,位于 type* 类型的地址 BaseAddress ----BaseAddress + width * ty + tx

为了使这些访问完全合并,线程块的宽度和数组的宽度都必须是 warp 大小的倍数。这意味着如果一个数组的宽度不是这个大小的倍数,如果它实际上分配了一个宽度向上舍入到这个大小的最接近的倍数并相应地填充它的行,那么访问它的效率会更高。


本地变量:

本地内存访问仅发生在可变内存空间说明符中提到的某些自动变量上。编译器可能放置在本地内存中的变量是:

  • 无法确定它们是否以常数索引的数组,
  • 会占用过多寄存器空间的大型结构或数组,
  • 如果内核使用的寄存器多于可用寄存器(这也称为寄存器溢出),则为任何变量。

检查 PTX 汇编代码(通过使用 -ptx 或 -keep 选项进行编译)将判断在第一个编译阶段是否已将变量放置在本地内存中,因为它将使用 .local 助记符声明并使用 ld 访问 .local 和 st.local 助记符。即使没有,后续编译阶段可能仍会做出其他决定,但如果他们发现它为目标体系结构消耗了过多的寄存器空间:使用 cuobjdump 检查 cubin 对象将判断是否是这种情况。此外,当使用 --ptxas-options=-v 选项编译时,编译器会报告每个内核 (lmem) 的总本地内存使用量。请注意,某些数学函数具有可能访问本地内存的实现路径。

共享内存:

因为它是片上的,所以共享内存比本地或全局内存具有更高的带宽和更低的延迟。

为了实现高带宽,共享内存被分成大小相等的内存模块,称为 banks,可以同时访问。因此,可以同时处理由落在 n 个不同存储器组中的 n 个地址构成的任何存储器读取或写入请求,从而产生的总带宽是单个模块带宽的 n 倍。

但是,如果一个内存请求的两个地址落在同一个内存 bank 中,就会发生 bank 冲突,访问必须串行化。硬件根据需要将具有bank冲突的内存请求拆分为多个单独的无冲突请求,从而将吞吐量降低等于单独内存请求数量的总数。如果单独的内存请求的数量为 n,则称初始内存请求会导致 n-way bank 冲突。

常量内存:

常量内存空间驻留在设备内存中,并缓存在常量缓存中。

然后,一个请求被拆分为与初始请求中不同的内存地址一样多的单独请求,从而将吞吐量降低等于单独请求数量的总数。

然后在缓存命中的情况下以常量缓存的吞吐量为结果请求提供服务,否则以设备内存的吞吐量提供服务。

texture & surface memory:

纹理和表面内存空间驻留在设备内存中并缓存在纹理缓存中,因此纹理提取或表面读取仅在缓存未命中时从设备内存读取一次内存,否则只需从纹理缓存读取一次。纹理缓存针对 2D 空间局部性进行了优化,因此读取 2D 中地址靠近在一起的纹理或表面的同一 warp 的线程将获得最佳性能。此外,它专为具有恒定延迟的流式提取而设计;缓存命中会降低 DRAM 带宽需求,但不会降低获取延迟。

因为纹理或表面获取读取设备内存具有一些优势,可以使其成为从全局或常量内存读取设备内存的有利替代方案:

  • 如果内存读取不遵循全局或常量内存读取必须遵循以获得良好性能的访问模式,则可以实现更高的带宽,前提是纹理提取或表面读取中存在局部性;
  • 寻址计算由专用单元在内核外部执行;
  • 打包的数据可以在单个操作中广播到单独的变量;
  • 8 位和 16 位整数输入数据可以选择转换为 [0.0, 1.0] 或 [-1.0, 1.0] 范围内的 32 位浮点值

最大化指令吞吐量:

基本策略:

为了最大化指令吞吐量,应用程序应该:

  • 尽量减少使用低吞吐量的算术指令;这包括在不影响最终结果的情况下用精度换取速度,例如使用内部函数而不是常规函数(内部函数在内部函数中列出),单精度而不是双精度,或者将非规范化数字刷新为零;
  • 最大限度地减少由控制流指令引起的发散 warp,
  • 减少指令的数量,例如,尽可能优化同步点或使用受限指针

Single-Precision Floating-Point Division:

为了保留 IEEE-754 语义,编译器可以将 1.0/sqrtf() 优化为 rsqrtf(),仅当倒数和平方根都是近似值时(即 -prec-div=false 和 -prec-sqrt=false)。因此,建议在需要时直接调用 rsqrtf()

Single-Precision Floating-Point Square Root:

单精度浮点平方根被实现为倒数平方根后跟倒数( (x^(-1/2))^(-1) ),而不是倒数平方根后跟乘法( x*x^(-1/2) ),因此它可以为 0 和无穷大提供正确的结果。

(应该是0*inf 会产生nan?)

Sine and Cosine:

sinf(x)、cosf(x)、tanf(x)、sincosf(x) 和相应的双精度指令更昂贵,如果参数 x 的量级很大,则更是如此。

参数缩减代码包括两个代码路径,分别称为快速路径和慢速路径。

快速路径用于大小足够小的参数,并且基本上由几个乘加运算组成。慢速路径用于量级较大的参数,并且包含在整个参数范围内获得正确结果所需的冗长计算。
由于慢速路径比快速路径需要更多的寄存器,因此尝试通过在本地内存中存储一些中间变量来降低慢速路径中的寄存器压力,这可能会因为本地内存的高延迟和带宽而影响性能。目前单精度函数使用28字节的本地内存,双精度函数使用44字节。但是,确切的数量可能会发生变化。

由于在慢路径中需要进行冗长的计算和使用本地内存,当需要进行慢路径缩减时,与快速路径缩减相比,这些三角函数的吞吐量要低一个数量级。

Integer Arithmetic:

整数除法和模运算的成本很高,因为它们最多可编译为 20 条指令。在某些情况下,它们可以用按位运算代替:如果 n 是 2 的幂,则 (i/n) 等价于 (i>>log2(n)) 并且 (i%n) 等价于 (i&(n- 1)); 如果 n 是字母,编译器将执行这些转换。

Half Precision Arithmetic:

为了实现 16 位精度浮点加法、乘法或乘法加法的良好性能,建议将 half2 数据类型用于半精度,将 __nv_bfloat162 用于 __nv_bfloat16 精度。然后可以使用向量内在函数(例如 __hadd2、__hsub2、__hmul2、__hfma2)在一条指令中执行两个操作。使用 half2 或 __nv_bfloat162 代替使用 half 或 __nv_bfloat16 的两个调用也可能有助于其他内在函数的性能,例如 warp shuffles。


Type Conversion:

有时,编译器必须插入转换指令,从而引入额外的执行周期。情况如下:

  • 对 char 或 short 类型的变量进行操作的函数,其操作数通常需要转换为 int,
  • 如果没有f后缀定义,浮点数会自动识别
    (转换)为双精度(由 C/C++ 标准规定)。

控制流指令:

任何流控制指令(if、switch、do、for、while)都可以通过导致相同warp的线程发散(即遵循不同的执行路径)来显着影响有效指令吞吐量。如果发生这种情况,则必须对不同的执行路径进行序列化,从而增加为此warp执行的指令总数

为了在控制流取决于线程 ID 的情况下获得最佳性能,应编写控制条件以最小化发散warp的数量。这是可能的,因为正如 SIMT 架构中提到的那样,整个块的 warp 分布是确定性的。一个简单的例子是当控制条件仅取决于 (threadIdx / warpSize) 时,warpSize 是 warp 大小。在这种情况下,由于控制条件与warp完全对齐,因此没有 warp 发散。

有时,编译器可能会展开循环,或者它可能会通过使用分支预测来优化短 if 或 switch 块,如下所述。在这些情况下,任何 warp 都不会发散。程序员还可以使用#pragma unroll 指令控制循环展开

同步指令:

对于计算能力为 3.x 的设备,__syncthreads() 的吞吐量为每个时钟周期 128 次操作,对于计算能力为 6.0 的设备,每个时钟周期为 32 次操作,对于计算能力为 7.x 和 8.x 的设备,每个时钟周期为 16 次操作。对于计算能力为 5.x、6.1 和 6.2 的设备,每个时钟周期 64 次操作。

最小化内存抖动:

基本原则:

  • 尝试根据手头的问题调整分配大小。不要尝试使用 cudaMalloc / cudaMallocHost / cuMemCreate 分配所有可用内存,因为这会强制内存立即驻留并阻止其他应用程序能够使用该内存。这会给操作系统调度程序带来更大的压力,或者只是阻止使用相同 GPU 的其他应用程序完全运行。
  • 尝试在应用程序的早期以适当大小分配内存,并且仅在应用程序没有任何用途时分配内存。减少应用程序中的 cudaMalloc+cudaFree 调用次数,尤其是在性能关键区域。
  • 如果应用程序无法分配足够的设备内存,请考虑使用其他内存类型,例如 cudaMallocHost 或 cudaMallocManaged,它们的性能可能不高,但可以使应用程序取得进展。
  • 对于支持该功能的平台,cudaMallocManaged 允许超额订阅,并且启用正确的 cudaMemAdvise 策略,将允许应用程序保留 cudaMalloc 的大部分(如果不是全部)性能。cudaMallocManaged 也不会强制分配在需要或预取之前驻留,从而减少操作系统调度程序的整体压力并更好地启用多原则用例。

学习资料:

1.CUDA_C++_Programming_Guide

2.NV企业开发者社区官方的一系列技术博客

(对比了一下,就是CUDA C++ Programming Guide。但是感觉像是机翻,有些语句看的我难受qwq)

3.《CUDA C Programming Guide》(《CUDA C 编程指南》)导读 - 知乎

你可能感兴趣的:(图形学学习笔记,硬件架构,缓存,性能优化,GPU,CUDA)