CUDA 学习(十九)、优化策略4:线程使用、计算和分支

一、线程内存模式

        把应用程序分解成大小合适的网格、线程块和线程,是保证CUDA 内核性能的关键环节之一。包括GPU在内的几乎所有的计算机设计,内存都是瓶颈。线程布局的一个糟糕的选择通常也会导致一个明显影响性能的内存模式。


二、非活动线程

        尽管有数以千计的线程是闲置的,但是它们并不是免费的。非活动线程的问题有两个方面。首先,只要线程束中的一个线程是活跃的,那么对应线程束就保存活跃,可供调度,并且占用硬件资源。然而只有有限数目的线程束可以在调度被调度(2个时钟周期)。以下两种方式都是无意义的:在多个CUDA 核上调度只含一个线程束或者在一个CUDA 核上调度而剩下15个闲置。然而,对于一个有分支的执行流,当线程束内的活动线程只剩一个时,硬件所做的正是属于上述无意义的事情。

       非活动的线程束本身也不是免费的。虽然SM 内部关心的是线程束,而不是线程块,然而外部调度器只能向SM而不是线程束调度线程块。因此,如果每个线程包含只有一个活动的线程束,那么仅有6-8个线程束以供SM 从中选择进行调度。通常根据计算能力的版本和资源使用情况,在一个SM 中容纳多达64个活跃的线程束。现在存在明显的一个问题,因为线程级的并行模型(TLP)依赖于大量的线程来隐藏内存和指令延迟。随着活跃线程束数量减少,SM 通过TLP 隐藏延迟的能力也明显下降。一旦超过某个程度,就会伤害到性能尤其是当线程束仍在访问全局内存的时候。


三、一些常见的编译器优化

1、复杂运算简化

      当访问数组的索引时,通常未优化的编译器代码将使用:

       array_element_address = index * element size

       这可以由两种技术之一更有效的取代。第一技术,首先,我们必须将数组的基址加载到一个基址寄存器中的地址中。那么我们访问可表示成基址加偏移。第二技术,即某些指令(乘、除)比其他指令(加)计算花费更高的代价。而优化试图以更高的(或更快)的操作取代高代价的操作。该技术同时适用于CPU和GPU。特别地,在计算能力2.1设备上,整数加法是整数乘法吞吐量的3倍。

2、循环不变式分析

      循环不变式分析查找在循环体内不变的表达式,并将其移动到循环体外。

3、循环展开

      循环展开是一种技术,旨在确保在运行一个循环的开销内完成一个合理数量的数据操作。在GPU中,这种方法限制的是寄存器的数目。由于GPU最多有64个和128个,有相当大的余地可以展开小的循环体,同时实现良好的加速。

       NVCC 编译器支持 #pragma unroll 指令,它会自动全部的常量次循环。当循环次数不是常数,它将不是展开。也可以指定 #pragma unroll 4, 其中的4 可以替换为程序员想要的任意值。通常情况下,4个或8个会工作得很好,但超出太多将使用过多寄存器,这会导致寄存器溢出。

4、循环剥离

       循环剥离是循环展开的增强技术,常用在循环次数不是循环展开大小的整数倍时。在这里,最后的数次循环分离出来,单独执行,然后展开循环的主体。

       当使用 #pragma loop unroll N 指令时,编译器将展开循环,使得迭代次数不超过循环边界,并在循环未端自动插入循环剥离代码。

5、窥孔优化

      这种优化寻找那些可以被同功能的、更复杂的指令代替的指令组。典型的例子是在乘法之后紧跟加法指令,常常在增益和偏移式计算中碰到。这种方式的结构可替代为更复杂的madd(乘法和加法)指令,从而将指令数目从两个减少到一个。

      其他类型的窥孔优化包括控制流简化,代数运算简化和删除不会执行的代码。

6、公共子表达式和折叠

      先看下面的两个例子:

      const int a = b[base + 1] * c[base + i];

      或者

      const int a = b[NUM_ELEMENTS-1] * c[NUM_ELEMENTS-1];

      在这个例子中,数组 b 和 c 由参数base 和 i 来索引。如果这些参数是在局部范围内起作用,则编译器可以统一计算索引(base + 1),并将该值增加到数组b 和c 的起始地址,同时增加到每个参数的工作地址。但是,如果任一个索引参数是全局变量,计算就必须重复,因为任何一参数都可能已被其他同时运行的线程所改变。如果只有单个线程时,可以放心地删除第二步的计算。但使用多线程时,上述操作也可能是安全的,但编译器无法确切知道所以通常会进行两次计算。

       在第二个例子中,语句NUM_ELEMENTS-1被重复计算。如果我们假设NUM_ELEMENTS 是一个宏定义,然后预处理器将其用实际值取代,所以我们可能得到b[1024-1] * c[1024-1]。显然,这两种情况下,1024-1 可以用1023代替。但如果NUM_ELEMENTS 是一个形参,正如很多内核调用中出现的那样,我们类型的优化是不可用的。此时,我们又退回到公共子表达式优化的问题。

       因此,要注意,在函数中使用常量参数,或在全局内存中包含这样的参数,可能会限制编译器对代码进行优化的能力。然后,你必须确保这些公共子表达没有出现在源代码中。一般地,消除常见的子表达式,可是代码更容易理解并会提高性能。


四、分支

        GPU 执行代码以线程块或线程束为单位。一条指令只被解码一次并被分发到一个线程束调度器中。在那里它将保持在一个队列中直到线程束调度器把它调度给一个由32个执行单元组合的集合,这个集合将执行该指令。

        这个策略将指令读取和解码的时间分摊给了N个执行单元。其本身与旧式的向量机器非常相似。不过,主要的差异还在于CUDA 并不需要每条指令都如此执行。如果代码中有一条分支并且只有几条指令处在该分支上,则这几条指令将会进入分支,而其他的指令在分支点等待。

       这一读取/ 解码逻辑随后为那些分支线程读取指令流而其他的线程只无视它即可。实际上,线程束中的每个线程都有一个表示它执行与否的标志位。那些不在分支上的线程将清除标志位。相反,那些在分支上的线程会设置标志位。

      GPU 将代码编译到一个叫做PTX (并行线程执行指令集架构)的虚拟汇编系统中。这很像Java 的字节码,它也是一种虚拟汇编语言。它既可以在编译时也可以在运行时解释成真的、能够在设备中执行的二进制代码。编译时的解释仅插入了一些真实的二进制码到应用程序中,插入的二进制码依赖于你在命令行中选择的架构(-arch开关)。


五、寄存器的使用

       寄存器是GPU 上最快的存储器机制。它们是达到诸如设备峰值性能等指标的唯一途径。然而,它们数量非常有效。

       要在SM 上启动一个块,CUDA 运行时将会观察块对寄存器和共享内存情况的使用。如果有足够的资源,该块将启动。如果没有,那么块将不启动。驻留在SM 中的块数量会有所不同,但通常在相当复杂的内核上可以启动多达6个块,在简单内核上则可达8个块(开普勒则多达16个块)。实际,块数并不是主要的考虑因素。关键的因素是整体的线程束相对于支持数量的百分比。

       减少寄存器的用量通常可以通过重新排列C语言源代码来实现。通过将变量的赋值和使用靠的更近,就可以是编译器重用寄存器。因此,可以在内核的开始处就声明a、b、c。实际上,如果它们只是稍后才会在内核中使用,经常会发现通过把变量的创建和赋值移动到实量,因为它们处于内核中不同的而且没有联系的阶段。


       


  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值