本章内容:
-
学习CUDA指令及其在应用程序行为中的作用
-
单浮点数和双浮点数的精确度对比
-
有关标准函数及CUDA内部函数的性能和精确度的实验
-
从不安全的内存访问中发现未定义行为
-
理解运算指令的意义和使用不当所产生的后果
当决定使用CUDA处理一个特殊的应用程序时,通常主要应该考虑的是GPU的计算吞吐量
可以将应用程序分为两类:
-
I/O密集型
-
计算密集型
本章重点介绍计算密集型应用
本章节重点理解不同低级原语的性能、数值精确度和线程安全性方面的优缺点
知道内核代码在什么时候被编译成一条原语或其他语句,能让你根据需求调整编译器生成的代码
例子:
这种乘法后紧跟加法的算术模式被称为乘法加,或者MAD
一个简单的编译器会把一个MAD指令转化成两个算术指令:先进行乘法运算紧接着进行加法运算。因为这种模式很常见,所以现代运算结构(包括NVIDIA GPU)都支持MAD指令。因此,执行一个MAD的结果是循环次数减少了一半。这种性能的提升并不是没有代价的。一个MAD指令的数据准确性往往比单独的乘法和加法指令的要低。
本章所讨论的内容都将在一个理想的应用程序行为和另一个理想的应用程序之间进行权衡。
7.1 CUDA指令概述:
指令是处理器中的一个逻辑单元
CUDA指令可能很陌生,但是知道CUDA内核代码什么时候会产生不同指令以及高级语言如何转化为指令,却是很重要的
对两个功能等效指令却在性能、精确度和正确性上有所不同
本节涵盖了显著影响CUDA内核生成指令的3大因素
就是简单的介绍一下而已, 具体的使用在下一节
- 浮点运算
浮点运算是针对于非整数值的计算,并且会影响CUDA程序的精确度和性能 - 内置和标准函数
虽然内置和标准的函数使用相同的数学运算,但有不同的精确度和性能 - 原子操作
当调用多个线程执行操作时,原子指令确保了程序执行的正确性
浮点指令:
这里还是讲到计组中的IEEE754标准的浮点数
直接看计组更得劲
这里就是介绍了一下C中浮点数的特点, 并没有涉及到具体的CUDA知识
内部函数和标准函数:
CUDA还将所有算数函数分成内部函数和标准函数
标准函数用于支持可对主机和设备进行访问并标准化主机和设备的操作
标准函数包含来自于C标准数学库的数学运算,如sqrt、exp和sin, 也包括单指令运算如乘法和加法
而CUDA内置函数只能对设备代码进行访问
如果一个函数是内部函数或是内置函数,那么在编译时对它的行为会有特殊响应,从而产生更积极的优化和更专业化的指令生成
如三角函数指令, 很多都是直接在GPU上通过硬件实现的, 执行起来有很高的效率
在CUDA中, 同样存有很多与标准函数功能相同的内部函数, 如:
- 标准函数中的双精度浮点平方根函数也就是sqrt
- 有相同功能的内部函数是
__dsqrt_rn
- 还有执行单精度浮点除法运算的内部函数:
__fdividef
内部函数分解成了比与它们等价的标准函数更少的指令
这会导致内部函数比等价的标准函数更快,但数值精确度却更低
因此可以通过精确度考虑选用标准函数还是内部函数
原子操作指令:
一条原子操作指令用来执行一个数学运算,此操作是一个独立不间断的操作,且没有其他线程的干扰
这里与计组中的概念有一点差异, CUDA中的原子操作指的是数学运算, 即通过函数实现具有原子性的数学运算
所有计算能力为1.1或以上的设备都支持原子操作, 也就是基本上能在所有版本上的显卡上使用原子操作
Kepler型全局原子内存操作比Fermi型操作更快,吞吐量也显著提高了
这里简单介绍了一下数据竞争的情况, 这里就不阐述了
原子运算函数分为3种:
- 算术运算函数
- 按位运算函数
- 替换函数
原子替换函数可以用一个新值来替换内存位置上原有的值, 并返回最初的值,它可以是有条件的也可以是无条件的
虽然原子函数没有精确度上的顾虑(而内部函数需要考虑精确度),但是它们的使用可能会严重降低性能
7.2 程序优化指令:
用于优化程序的指令,有很多的选择:单精度或双精度浮点值、标准或内部函数、原子函数或不安全访问。一般情况下,每一个选择在性能、精确度和正确性上都有不同表现。对于所有应用程序来说并没有最佳的选择,最佳决策取决于应用程序的要求
单精度 & 双精度的比较
这里介绍了单精度 & 双精度的耳熟能详的区别, 不赘述了
首先的区别就是精度和性能:
-
精度:
IEEE754中很明确的阐述了精度的区别
-
速度:
这玩意明显受限与硬件, 尤其是双精度单元与单精度单元的占比
还有注意的是:
与C++编译器相同, 任何不正确的省略尾数f的声明(pi=3.14i59)都会自动地被NVCC编译器转换成双精度数
标准函数 & 内部函数的比较:
之前讲到:
标准函数支持大部分的数学运算
而许多等效的内部函数能够使用较少的指令、改进的性能和更低的数值精确度,实现相同的功能
标准函数和内部函数的可视化:
使用nvcc的—ptx
指令能够让编译器在并行线程执行(PTX)和指令集架构(ISA)中生成程序的中间表达式,而不是生成一个最终的可执行文件
PTX类似于x86编程里面的程序集,它提供了一个你所编写的内核代码之间的中间表达式,该指令在GPU上执行
所以可以用这玩意来学习了解内核的低级别执行路径
使用这俩来比较标准函数和内部函数:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hWvroIku-1613737811466)(https://gitee.com/janusv/typora-pic-bed/raw/master/img/20210218195338.png)]
而后使用以下指令编译:
由于之前并未学习过原指令, 文件根本读不懂, 所以这里就直接按照书中的步骤走了
函数名可能会因编译器版本的不同而不同
指令代码量:
对于内部函数:
其实现仅仅需要17行代码, 并且有7条使用了浮点运算
而标准函数powf:
其在书中的CUDA 5.0 Toolkit中使用344行代码
执行速度:
使用内部函数相较于标准函数来说,速度提升了将近24倍,获得了巨大的性能提升
但是可以看到, 不论是标准库还是内部函数, 其值与host结果都不同
标准库可以归咎为浮点误差, 但内建函数明显差了一个数量级, 所以误差是相当大的
CPU到GPU的移植:
通常的步骤:
将传统应用从只有CPU的系统移植到CUDA系统中,接着通过比较传统应用结果与使用CUDA的执行结果,来验证程序移植的数值精确性
但是由于主机和设备上的浮点运算都存在固有的不精确性,有时很难指出一个输出结果与另一个输出结果哪个更精确, 因此,必须考虑数值差异并做出恰当的移植计划,而且有必要的话需要设置允许的误差范围
操纵指令生成:
在大多数情况下,编写的内核代码转换为GPU指令集这一过程是由CUDA编译器完成的, 通常很少会去检查或手动修改指令
但CUDA仍然支持引导编译器倾向于实现良好的性能或准确性或者达到两者的平衡, 其提供了两种方法:
- 编译器标志
- 内部或标准函数调用
之前提到了可以使用内部函数来替换掉算数运算, 以实现内核操作
而编译器标志提供了一个更自动, 全句话的方式来操纵编译器指令的生成
以浮点数MAD指令为例:
nvcc的–fmad选项可全局性地启用或禁用FMAD整个编译单元的优化
--fmad=true
将启用FMAD指令来优化性能--fmad=false
将阻止编译器混合任何乘法和加法,这虽然有损性能但可能提高应用程序的数值精度
如以下例子:
用“–fmad=true”为foo函数生成PTX,也就是为内核产生一个算术指令:
如果这个内核用“–fmad=false”进行编译,在MAD指令的位置会出现一对指令,如下所示
可以看到nvcc没有将乘法和加法融合为一个MAD指令
其余的编译器优化标志:
除了–fmad选项,CUDA还包含一对用于控制FMAD指令生成的内部函数:__fmul
和__dmul
,这些函数用于实现单精度浮点型和双精度浮点型乘法
但是这些函数不会影响乘法运算的性能, 而是防止nvcc将乘法优化成MAD
所以通常被用在局部优化
内部函数的不同版本:
许多浮点型内部函数(包括__fadd,__fsub,__fmul
等)在函数名中都使用两个后缀字符,这明确指出了浮点四舍五入的模式(在表7-4中已列出)
小结:
了解原子指令:
本节将学习如何使用原子操作,并学习在高并发环境下的共享数据上如何执行正确的操作
从头开始:
这里用一个原子级运算符 CAS(比较并交换)
作为引例:
CAS将3个内容作为输入:
- 内存地址
- 存储在此地址中的期望值
- 以及实际想要存储在此位置的新值
并执行以下操作:
-
读取目标地址并将该处地址的存储值与预期值进行比较
- 如果存储值与预期值相等,那么新值将存入目标位置
- 如果存储值与预期值不等,那么目标位置不会发生变化
-
返回目标地址中的值
使用返回值可以用来检查一个数值是否被替换成功。如果返回值等于传入的期望值,那么CAS操作一定成功了
相关的函数:
__DEVICE_ATOMIC_FUNCTIONS_DECL__ int atomicCAS(int * address, int compare, int val)
- address
目标内存地址 - compare
预期值 - val
是实际想写入的新值
例子:
利用atomicCASe实现原子加法
使用循环的原因是由于在多线程环境下, 有可能执行失败, 所以通过循环不断比较值, 直到相同
内置的CUDA原子函数:
原子操作的成本:
原子操作可能要付出很高的性能代价
- 当在全局或共享内存中执行原子操作时,能保证所有的数值变化对所有线程都是立即可见的
所以原子操作的内存读写都是没有缓存的 - 共享地址冲突的原子访问可能要求发生冲突的线程不断地进行重试,就比如上头的myAtomicAdd循环重试
执行自定义原子操作时所必须的步骤 - 一个线程束中的多个线程在相同的内存地址发出一个原子操作,线程束执行是序列化的
正如上头的循环重试, 每次只能有一个线程成功执行命令
这里用一个简单的例子来介绍原子操作的成本:
包含了两个核函数,分别叫作atomics和unsafe, 一个使用原子操作, 另一个不使用
运行结果:
使用atomics版本的运行时间是unsafe运行时间的300倍还要多
所以应该在非必要时避免使用原子操作
限制原子操作的性能成本:
这里介绍了一个策略在减少原子操作的成本:
如图所示, 其使用一个中间值储存每个线程的结果 ( 中间值储存在低访存成本的共享内存中, 或使用洗牌指令在线程间传递), 并在最后一步才使用原子操作将结果写入全局内存
原子级浮点支持:
所有原子函数都不支持双精度数值的运算
大部分内容与之前的myAtomicAdd示例类似
主要的不同是atomicCAS数值转换的传入和传出,其使用的是CUDA提供的各种类型的转换函数:
综合范例:
本节使用一个粒子模拟的核函数NBody来讲解:
NBody实现要进行两个全局统计:
- 是否有任何粒子已经超过了相对于原点的某个距离
这个只需要定量分析, 所以仅需要一个bool的flag, 并可以使用不安全访问 - 有多少粒子移动的速度比指定速度快
这个需要精确统计, 所以需要使用原子操作
单精度 & 双精度的结果:
最后一个错误结果的输出是主机和设备端计算的粒子位置之间的平均差
再来对比一下几个编译器标志:
这些结果与预期的结果完全相吻合,并且证明了编译器标志使用的重要性。对于用来最大化性能的标志,总的执行时间提高了2.25倍。对于用来最大化精度的标志来说,它消除了主机实现时的数值偏差
总结:
当建立一个CUDA应用程序时,你必须明白以下几点对性能和数值精度的影响:浮点型运算,标准和内部函数以及原子操作。
在本章中,对于怎样引导编译器指令生成核函数有了更详细的介绍。CUDA编译器和函数库通常隐藏了底层细节,这对于程序员来说是把双刃剑。自动编译器的优化减少了一些优化负担,但可能会导致内核中数据转化变为不可见。这种不透明性会导致数值问题调试困难。此外,如果对这些性能调节方案了解的不够全面,那么在程序优化时你可能想不出合适的方法。
通过本章描述的理论方法,你将对优化应用程序的性能、精度和正确性有了更好的准备。NBody例子说明了与以下内容相关的性能提升:
-
单精度和双精度浮点数运算
-
内部函数和标准函数
-
原子级访问和不安全访问
这些内容可以帮助你充分利用GPU的计算吞吐量,且不以牺牲应用程序的正确性为代价。接下来,你将要学习如何通过CUDA加速库和基于OpenACC指令的编译器来提高编程效率。