本节书摘来自华章计算机《CUDA C编程权威指南》一书中的第3章,第3.5节,作者 [美] 马克斯·格罗斯曼(Max Grossman),译 颜成钢 殷建 李亮,更多章节内容可以访问云栖社区“华章计算机”公众号查看。
3.5 展开循环
循环展开是一个尝试通过减少分支出现的频率和循环维护指令来优化循环的技术。在循环展开中,循环主体在代码中要多次被编写,而不是只编写一次循环主体再使用另一个循环来反复执行的。任何的封闭循环可将它的迭代次数减少或完全删除。循环体的复制数量被称为循环展开因子,迭代次数就变为了原始循环迭代次数除以循环展开因子。在顺序数组中,当循环的迭代次数在循环执行之前就已经知道时,循环展开是最有效提升性能的方法。考虑下面的代码:
如果重复操作一次循环体,迭代次数能减少到原始循环的一半:
从高级语言层面上来看,循环展开使性能提高的原因可能不是显而易见的。这种提升来自于编译器执行循环展开时低级指令的改进和优化。例如,在前面循环展开的例子中,条件i< 100只检查了50次,而在原来的循环中则检查了100次。另外,因为在每个循环中每个语句的读和写都是独立的,所以CPU可以同时发出内存操作。
在CUDA中,循环展开的意义非常重大。我们的目标仍然是相同的:通过减少指令消耗和增加更多的独立调度指令来提高性能。因此,更多的并发操作被添加到流水线上,以产生更高的指令和内存带宽。这为线程束调度器提供更多符合条件的线程束,它们可以帮助隐藏指令或内存延迟。
3.5.1 展开的归约
你可能会注意到,在reduceInterleaved核函数中每个线程块只处理一部分数据,这些数据可以被认为是一个数据块。如果用一个线程块手动展开两个数据块的处理,会怎么样?以下的核函数是reduceInterleaved核函数的修正版:每个线程块汇总了来自两个数据块的数据。这是一个循环分区(在第1章中已介绍)的例子,每个线程作用于多个数据块,并处理每个数据块的一个元素:
注意要在核函数的开头添加的下述语句。在这里,每个线程都添加一个来自于相邻数据块的元素。从概念上来讲,可以把它作为归约循环的一个迭代,此循环可在数据块间归约:
如下所示,全局数组索引被相应地调整,因为只需要一半的线程块来处理相同的数据集。请注意,这也意味着对于相同大小的数据集,向设备显示的线程束和线程块级别的并行性更低。图3-25所示为每个线程的数据访问。
向主函数添加下面的代码,调用新的核函数:
因为现在每个线程块处理两个数据块,我们需要调整内核的执行配置,将网格大小减小至一半:
现在编译和运行这些代码,出现以下结果:
即使只进行简单的更改,现在核函数的执行速度比原来快3.42倍。可以进一步展开以产生更好的性能吗?reduceInteger.cu文件包含着展开的核函数中其他的两个实现,如下所示:
相应的结果概括如下:
正如预想的一样,在一个线程中有更多的独立内存加载/存储操作会产生更好的性能,因为内存延迟可以更好地被隐藏起来。可以使用设备内存读取吞吐量指标,以确定这就是性能提高的原因:
结果总结如下,归约的展开测试用例和设备读吞吐量之间是成正比的:
3.5.2 展开线程的归约
__syncthreads是用于块内同步的。在归约核函数中,它用来确保在线程进入下一轮之前,每一轮中所有线程已经将局部结果写入全局内存中了。
然而,要细想一下只剩下32个或更少线程(即一个线程束)的情况。因为线程束的执行是SIMT(单指令多线程)的,每条指令之后有隐式的线程束内同步过程。因此,归约循环的最后6个迭代可以用下述语句来展开:
这个线程束的展开避免了执行循环控制和线程同步逻辑。
注意变量vmem是和volatile修饰符一起被声明的,它告诉编译器每次赋值时必须将vmem[tid]的值存回全局内存中。如果省略了volatile修饰符,这段代码将不能正常工作,因为编译器或缓存可能对全局或共享内存优化读写。如果位于全局或共享内存中的变量有volatile修饰符,编译器会假定其值可以被其他线程在任何时间修改或使用。因此,任何参考volatile修饰符的变量强制直接读或写内存,而不是简单地读写缓存或寄存器。
基于reduceUnrolling8,线程束的展开可以添加到归约核函数中,如下所示:
因为在这个实现中,每个线程处理8个数据块,调用这个内核的同时它的网格尺寸减小到1/8:
这个核函数的执行时间比reduceUnrolling8快1.05倍,比原来的核函数reduceNeigh-bored快8.65倍:
使用下面的命令,stall_sync指标可以用来证实,由于__syncthreads的同步,更少的线程束发生阻塞:
结果总结如下。通过展开最后的线程束,百分比几乎减半了,这表明__syncthreads能减少新的核函数中的阻塞。
3.5.3 完全展开的归约
如果编译时已知一个循环中的迭代次数,就可以把循环完全展开。因为在Fermi或Kepler架构中,每个块的最大线程数都是1 024(参见表3-2),并且在这些归约核函数中循环迭代次数是基于一个线程块维度的,所以完全展开归约循环是可能的:
用以下执行配置调用这个核函数:
内核时间再次有了小小的改善,它的执行比reduceUnrollWarps8快1.06倍,比原来的实现快9.16倍:
3.5.4 模板函数的归约
虽然可以手动展开循环,但是使用模板函数有助于进一步减少分支消耗。在设备函数上CUDA支持模板参数。如下所示,可以指定块的大小作为模板函数的参数:
相比reduceCompleteUnrollWarps8,唯一的区别是使用了模板参数替换了块大小。检查块大小的if语句将在编译时被评估,如果这一条件为false,那么编译时它将会被删除,使得内循环更有效率。例如,在线程块大小为256的情况下调用这个核函数,下述语句将永远是false:
编译器会自动从执行内核中移除它。
该核函数一定要在switch-case结构中被调用。这允许编译器为特定的线程块大小自动优化代码,但这也意味着它只对在特定块大小下启动reduceCompleteUnroll有效:
注意,最大的相对性能增益是通过reduceUnrolling8核函数获得的,在这个函数之中每个线程在归约前处理8个数据块。有了8个独立的内存访问,可以更好地让内存带宽饱和及隐藏加载/存储延迟。可以使用以下命令检测内存加载/存储效率指标:
表3-6总结了所有核函数的结果。在第4章,将会更加详细地介绍全局内存访问,并且会对内存访问如何影响内核性能有更深的了解。