zl程序教程

您现在的位置是:首页 >  其它

当前栏目

CUDA优化冷知识24|函数和指令使用的选择和优化

使用 函数 优化 选择 知识 指令 24 CUDA
2023-06-13 09:16:30 时间

这一系列文章面向CUDA开发者来解读《CUDA C Best Practices Guide》 (CUDA C最佳实践指南)。

上一次我们讲到:CUDA优化冷知识23|如何执行配置优化以及对性能调优的影响

今天的主要内容是<优化指南>手册里面,对一些函数和指令使用的选择和优化。大致分为普通的计算函数/指令,和访存相关的方面。

我们先从计算函数/指令开始。

首先上去的小节,是关于整数除法和求余操作的优化写法。当除法A / B, 和求余A % B的时候,如果B是2的整数次方,也就是B = 2^N的时候,前者A / B可以直接写成移位操作A >> N;后者A % B, 可以直接写成逻辑与操作A & (N - 1)。无论是移位操作,还是逻辑与操作,都是单周期的指令,远比老老实实的除法和求余快得多。

手册本节指出了,当B是编译时刻的2^N形式的常数的时候,编译器会自动发现这一点,同时自动为你进行这个优化。但是如果B不能在编译时刻确定,例如作为一个参数,B传递给了kernel,此时为了避免进行昂贵的除法和求余,可以考虑手工将B转换成指数值,然后手工进行移位和逻辑与操作。例如原本要传递进来256, 现在可以传递进来8(也就是log2(256)), 然后直接A >> 8和A && (8 - 1)即可,从而规避了昂贵的代码产生。

这是第一小节。第二小节则依然是说的整数,主要涉及到在使用下标和循环控制变量的时候,对有符号整数和无符号整数的选择。并讨论了C语言默认为有符号整数时候,编写代码的人如果偷懒不写上unsiged字样,则在循环控制变量和下标计算上,将生成较为劣化的代码。

小节说明了,这是因为无符号整数的溢出和累加都很方便,而有符号的则需要处理溢出的特殊情况,需要占用额外的指令。

我们这里以前忽略过这点,今天我们用计算能力8.6上的指令生成,分别测试了默认情况,和标注了unsiged字样的整数,在这两种情况下带来的优势——我们给读者测试了对于常见的形如p[i * 8] = 0,当i是int和unsigned int时候的,单语句的代码生成的效果对比,用来验证一下手册的这个优化的说法:

在i是无符号整数的时候,p[i * 8]编译生成了2条指令的序列。

指令(1):用LEA指令计算p的低32位地址累加i左移3位

指令(2):如果有进位溢出,p高32位+1

我们的GPU是32位机,只能每次进行32位整数运算,对于这p[i * 8]形式的64-bit最终地址计算,这已经是最优的代码生成了。

而在i是常规有符号的整数的时候,却编译生成3条指令的序列,多了一条:

(1)单独计算i * 8的值

(2)整数加法, 并得到是否溢出的标志

(3)根据溢出标志,执行32位符号扩展的LEA.HI.X.SX32指令。

你看,在使用下标的时候,在int i的定义身上,简单的加上unsigned的无符号标注,就能得到性能优化。

类似的,根据手册本小节的说法,当下标在循环里面的时候,编译器还可以对unsigned的下标,进行更强的替换处理(strength reduction,参考: en.wikipedia.org/wiki/S ), 例如我们在一个for(i)循环中的p[i * 8]的使用,发现了每次i的递增,乘以8被reduced到每次加8,和地址的计算等方面的指令生成,也有类似的优化效果。所以你付出的代价只是将声明变量的时候,添加一个unsigned标记,就可以得到显著的好处。这点值得考虑。

以及,本小节实际上说的是:对于循环变量尽量使用有符号的整数,理由是,无符号的行为是精确定义的,有符号没有精确定义溢出行为,所以编译器有更多的操作(优化)空间,但是我们编译测试发现是反的,建议读者们自己实验决定究竟是什么情况。

--刚才例子中的无符号情况的生成结果(cuobjdump), 一共两条指令。LEA和加法(8.6上用FP32 path的IMAD.X的A + 0 * B + 进位指令,模拟了A + 进位加法)。

--刚才例子中,有符号情况的生成结果, 一共三条指令(移位用FP32路径的IMAD.SHL模拟替换)。

也就是移位,第32位加法,高32位符号扩展加法。这三条。

(关于7.5和8.6上,用FP32路径上的IMAD/IMUL的指令模拟常规INT32指令,达到port平衡,是另外一个话题。这两个计算能力都能超过64指令/周期/SM的INT32的指令峰值上限,因为这种模拟替换和平衡,用nsight很容易发现这点)

好了。两个小节的整数指令方面的优化选择说完了,我们下面继续今天的主要内容,关于float方面的优化选择。

首先说的是,计算1分之根号X,本小节指出了,有单独的单精度和双精度的rsqrtf()、rsqrt(), 来直接完成求根号X,然后再求倒数的一体化运算。如果可能,尽量使用这个。会带来更好的效率和精度。编译器并不总是能将1.0 / sqrt()的写法,转换成对应的一体化函数版本的。

然后下一小节手册从上面两个相似名字的数学运算函数(结尾带有f和不带有它)开始,说了容易不小心将float写成double,并生成了double运算代码,导致速度降低很多的情况。主要有这两点:

(1)读者写代码的时候,如果不小心,使用1.0,而不是1.0f这样的常数,根据C的规则,含有这个常数的式子,将在运算过程中,提升到double进行运算,式子算完后,再转换回来成float进行赋值给lvalue对象。这样有了来回转换double<->float的指令开销,也有了慢速的double指令计算的开销。

(2)CUDA编译器实际上是一个C++编译器,在math_functions.h之类的头文件里面,有C++风格的重载。例如sqrt()函数,有double sqrt(double)的版本的,也有float sqrt(float)的版本的。如果用户不小心,在式子里面给出了double的中间结果作为参数,同时函数结尾没有显式的写出f()结尾,那么因为重载的同名函数存在,将实际上使用的是慢速的double版本的。也有生成慢速的代码。

所以我们读者应当尽量小心注意使用浮点常数和函数后面f结尾,避免生成慢速的代码(double总是要慢的,而且会占用更多的资源),特别是在家用卡上(8.6的家用卡走double路径将只有1/64的速度)。

我们的老朋友樊博士,对于忘记了f后缀写上,而导致代码变慢了很多很多,具有惨痛的教训。并在冬令营/夏令营上,给我们深刻的说过这点。

然后这小节还提了在进行概率统计之类的运算的时候,如果要使用正态分布的误差函数,特别要注意这点。因为erfcf()这个函数(注意f结尾),在单精度的时候特别快。例如我们在计算N(0, 0.5)的正态分布的2个西格玛内的概率的时候,使用float p = 1.0f - erfcf(1.0f / 0.707f);类似这种写法(注意好多f结尾),将特别快。

最后这小节还提到了,不仅仅我们浮点数有这种情况,8-bit和16-bit的整数,在直接在我们GPU上使用的时候,通常情况(不考虑深度学习时候的多个打包在一起的运算),都需要转换成32-bit整数,才能进行运算。这是因为我们的N卡,在进行整数计算的时候,是严格的32-bit机器,不像x86 CPU那样能就地干8-bit和16-bit指令。这样不小心就会导致额外的代价产生。

总之,适当的写法,和数据类型的使用,能避免转换的代价,和昂贵代码路径的生成。读者还是需要注意这里的优化的。

下一节则谈论了对我们读者们喜闻乐见的powf()、pow()(分别是float和double版本,如上面说过的)的通用幂函数运算时候的优化,主要针对了几种特殊的指数值,可以不用通用的幂运算完成:

如图,通用的指数运算的快速替换写法。

我们这里简单的举几个例子就好:

计算x的1/6次方,可以先计算一次x的平方根倒数,再计算一次立方根倒数,这样就得到1/6次方的值,而无需使用昂贵的pow之类的函数。再例如:x的2/3次方,可以先求一个立方根,然后再求一次平方,这样就快速得到了2/3次方。

注意这个快速替换表格里的公式,很多都使用了特殊的GPU上专用的函数,例如rsqrt, rcbrt(二次方和三次方根的倒数),而不是标准的C库(libm),在CPU上我们能见到的sqrt、cbrt(二次方和三次方根),如果我们读者从以前的代码编写经验来,可能喜欢使用嵌套两次立方根,得到1/9次方的值,我们不推荐读者这样来。因为特殊的rsqrtf()这种,可能在实现上具有更好的精度和性能。例如我们之前的章节知道过,SFU这种喜欢提供平方根的倒数的这种快速的接近,可能有助于性能的提升。总之我们建议保留此表格,直接使用里面的写法,而不是读者使用更熟悉的替换形式,为了能够保证足够的精度和性能。

好了,今天的内容暂时到这里。