B.23. #pragma unroll
By default, the compiler unrolls small loops with a known trip count. The #pragma unroll directive however can be used to control unrolling of any given loop. It must be placed immediately before the loop and only applies to that loop. It is optionally followed by an integral constant expression (ICE)6. If the ICE is absent, the loop will be completely unrolled if its trip count is constant. If the ICE evaluates to 1, the compiler will not unroll the loop. The pragma will be ignored if the ICE evaluates to a non-positive integer or to an integer greater than the maximum value representable by the int data type.
Examples:
本文备注/经验分享:
本章节主要说的是, Kernel内部的循环展开的问题.这是一个非常重要的优化措施. 我们都知道, 将一段CPU算法实现, 改写到GPU上的时候, 往往需要进行循环展开.从而取得原本CPU上的循环体内部的一些语句在GPU上的并行操作, 来得到高性能.所以很多CUDA讲座, 上去首先就说, 分析问题先看看是否能循环展开?但是本章节说的却不是这个, 而是指的kernel的代码内部,一个GPU上的线程在执行的过程中, 它所进行的循环(串行)处理, 通过本章节提供的#pragma unroll指示性语句, 要求编译器自动将kernel内部的循环展开的过程.该过程因为几乎是全自动的(只需要你添加一个#pragma), 同时很多情况下能取得不错的性能提升, 值得我们去了解分析.要知道为何kernel内部的循环展开后能提升性能, 我们需要分析循环本身的构成. 例如一个:
for (int i = 0; i < X; i ) { body goes here; }
这么一个典型的for循环, 真正干活的部分实际上只有循环体(loop body), 而前面的for那行, 则是所谓的循环控制部分. 该控制部分有一次性的开销(i = 0初始化), 每次循环时候的控制(i < X判断), 和每次循环都需要的操作(i ) 所以在实际干活的循环体之外, 这些循环控制的开销, 很多时候不能无视, 因为它们将将在SM上执行, 占用周期.所以能否将这些额外的控制占用的成本降低到最小, 则是取得性能提升的一个很关键因素.特别时对于小循环来说, 例如循环体干活部分只有1-2句很短小的指令的时候.此时循环控制的成本可能会超过本身干活的循环体的成本.而通过本章节的#pragma unroll操作, 例如对于一个原本16次循环, 每次循环都需要5条控制指令(假设), 和4条实际干活的指令的代码部分,展开后, 可能生成的指令会发生如下变化: (1)展开前, 初始化循环控制变量; again: 循环控制--循环变量增加; 循环控制--判断下次是否应当循环; 干活; 干活; 干活; 干活; 循环控制--计算调转地址; 循环控制--等待循环内部依赖的资源可用(例如soft barrier) 循环控制--执行判断结果下的调转到again (一共16次本跳转) 而变成展开后: (2) 干活; 干活; ... 干活; (一共4x5 = 20条) 你可以看到, 通过添加了#pragma unroll指令, 循环控制本身的成本倍彻底消除了. 这就如一家公司, 减少了在行政处理上的资源, 而将资源实际上分配给干活上,往往能够提升整体效率一样. 但是也如同一家公司, 不可能完全没有行政处理的部分, 很多循环不能像刚才的例子那样, 完全的展开.例如你刚才看到, 展开后的代码往往会变的增大. 当一个循环的次数非常巨大, 往往不可能完全展开.例如1000次循环, 展开后代码将爆掉你的GPU卡上的代码缓存.所以本章节的#pragma unroll N后面允许跟随一个常数N 例如将1000次循环, 每20次展开, 这样从原本的1000次循环, 每次干1个活,变成了循环50次(1000/20=50), 每次干20个活,这样虽然不能完全展开, 但控制成本变成了原本的1/20,同时编译器生成的目标代码体积, 也只增加了20倍,这样算是很折中的不错选择. 请注意本章节又引入了一个C 概念,integral constant expression(ICE),实际上ICE这个说法只见于C . C用户应当直接将它当成编译时刻的常量来理解(compile-time constant),实际上, 我们之前的所有章节中, 我们都时使用的编译时刻常量这个词, 而不是C 的ICE.用户需要注意手册提到的这里的前后部分.当你对循环自动展开这个操作有了基本了解后, 我们需要稍微深入的继续说一下它的优点.以及, 实物总是需要辩证的看两面, 还需要知道它的缺点.因为很多实践中, 过度或者不必要的展开反而会降低性能. 过犹不及几乎是对CUDA的所有方便都适用. 先看看#pragma unroll能都给你带来那些好处: 1)节省的人力. 很多CUDA例子代码中的规约过程, 最后的部分往往都是人工写的代码展开的. 例如你经常会看到: A[... 64] = ....; .....; A[... 32] = ....; .....; A[... 16] = ....; .....; A[... 8] = ....; .....; A[... 4] = ....; .....; A[... 2] = ....; .....; A[... 1] = ....; .....; ....
手工展开固然可以. 但增加了代码的行数, 和键盘的敲击量. 如果通过本章节的#pragma unroll操作. 你可以直接:
#pragma unroll 7 for (int i = 6; i >= 0; i--) { A[.... (1 << i)] = ...; ...; }
这样直接就可以等效于上面这段代码了, 注意有人会但心这里的移位操作(1 << i), 因为这个是能在编译时刻确定的常量, 编译器会执行进行编译时刻求值, 替换成64, 32, 16..这些的, 不用但心.这样很大程度的减少了程序员的工作量. 当然, 不适合那些以代码行数, 进行绩效考核的公司.后者我建议你怎么折腾怎么来, 不要适用本章节的循环自动展开. (2)除了(1)能带来编码时候的方便外, 以及, 之前说过的能消除(或者一定比例的降低)循环控制成本外. 循环展开还能带来另外一个好处.那就是自动的ILP. 还记得我们之前说过的TLP和ILP概念吗?TLP就是人人喜闻乐见的给你的显卡上一堆线程, 越多越好,而ILP则是尝试在kernel内部发掘线程内部(而是不TLP的线程间)的并行性, 从而提升性能.这个手册之前的章节提到过多次, 就不重复了. 而#pragma unroll方式的循环展开, 往往能自动带来ILP效果.这是因为, 循环体展开后, 往往会形成相似的重复(例如刚才的例子里面的: 干活; 干活; 干活; 干活; 这种重复模型),或者是干活[1], 干活[2], 干活[3]...这种类似的.因为展开后, 总是有重复的类似代码, 这些代码往往可以进行指令间并行.例如循环体是读取global memory, 增加1, 回写global memory,则编译器在接受到你的循环展开指示后, 会发现多个global memory的读取, 有可能允许同时进行中多份, 而不是必须等待一个读取完成, 才能 1,此时就有可能发掘出来额外的性能. 这点是unroll后, 除了消除了循环控制代码外, 能额外榨取性能的另外一个地方.请注意(2)点这种循环unroll能能带来ILP效果的说法非常重要. 特别是一些奇特的卡(Kepler),常规的读取global memory-立刻写入显存来完成设备端的显存复制的过程,如果不进行循环展开, 在Kepler这种卡上, 连显存的带宽都跑不满.但是展开后却可以.(好在, Fermi, Maxwell, Pascal都不需要这样, 只有Kepler这一代连基本操作都必须上ILP, 哪怕是循环自动展开提供的, 也是醉了), 此外, 除了这两点带来的性能提升外, (3)循环unroll后, 还能让编译器充分利用硬件的能力, 压榨每条指令的潜力, 提升性能: 例如我们经常写的循环体代码中, 有这样的计算: a[base i * 4] = ...; 其中i是循环控制变量, 每次 1,那么这个代码, 编译器在编译的时候, 必须生成正确计算地址的指令, 而目前, N卡几乎是严格的RISC机器, 需要依赖通用的SP,进行通用的的整数计算得到地址.这种计算将占用SP资源.但是如果循环展开后, 编译器有可能直接消除了地址计算, 例如: STS [R0], R1 STS [R0 4], R2 STS [R0 8], R3 STS [R0 12], R4 ... 然后循环多次后(例如20次展开): IADD R0, R0, 80; //下20次原本的循环只需要计算1次基地址即可.这样, 编译器可以直接利用访存指令内置的立即数偏移量(0, 4, 8, 12这些),消除了每次的地址计算占用的SP.
(注意不是完全消除了, 但是现在每20次只需要一条SP的IADD整数加法指令加上80而已,而不进行循环unroll, 则需要每次都至少一条IADD),这样通过这种方式, 释放了宝贵的SP资源(特别是卡计算, 而不是卡访存的kernel), 有助于性能提升.这种策略实际上对很多CPU一样有效.很多比较严格的RISC机器, 虽然指令都是严格的只能做一种操作的(RISC么! 欢迎搜索RISC), 但是它们的访存单元往往可以附带一次免费的加法辅助地址计算. 此时通过循环展开, 将有利于利用这种免费的辅助加法, 从而释放主计算单元. 以上这3点是主要的循环展开的好处.此外, 特别的在GPU上, 还存在第四点较少见的情况, 循环unroll后能消除local memory使用.我们都知道local memory相比寄存器是比较缓慢的, 但是有的时候, 编译器无法规避的要使用它. 例如对数组A[] for (....) { A[i % 3] = ... } 其中i是循环控制变量. 虽然这里最多会存在A[0], A[1], A[2]三个, 但是只要i难以在编译时刻确定, (例如你的i来自某个计算才进入循环, 或者循环内部有计算, 变换了i),编译器因为无法对寄存器进行索引操作, 不得不使用local memory(它可以被索引),此时将会导致缓慢的local memory传输.而一旦你对该循环进行了unroll后, 例如3X展开, 编译器有可能发现3X展开后的循环体中的对A[]的使用, 都是可以确定的下标, 从而消除了local memory使用, 提升了性能.我们曾经对某挖矿kernel进行过优化, 里面存在d[X]这种变量, 里面的X是0,1,不进行2X的unroll, 在CUDA 8.0下, 将导致生成STL/LDL(local memory写入和读取操作),进行了展开后, 只有寄存器到寄存器的操作, STL/LDL被消除了.因为展开后, 编译器发现了更多的下标的常数性. 这4点几乎是所有你能遇到的循环unroll的好处了.然而福祸总是相随出现的. 有好处也必然要付出代价. 我们接着说一下常见的坏处, 主要坏处有2个: (1)循环展开后, 生成的代码会变大. 此时将增加I-Cache的抖动, 特别是在你使用多个stream同时启动多个kernel的时候将更加显著.I-Cache的抖动将反向的降低性能.如果你用profiler分析Maxwell或者更高计算能力的卡上的kernel的时候, 发现了很大程度的卡在了"Instruction Fetch"(取指)上, 则往往代表你unroll过头了, 可以时当的降低一点unroll程度. (啥叫抖动?抖动就是一种cache不能稳定的提供缓冲过的数据的工作状态,如同你的汽车发动机在瑟瑟发抖而不能很好的输出马力一样,此时cache大量的时间在反复的进新内容, 然后淘汰出旧内容, 却不能很好的提供缓冲效果,你可以用profiler(上面)观察kernel,也可以直接感觉kernel的运行时间---cache剧烈抖动往往会导致明显的性能下降, 和运行时间增加) 这是第一点. 我建议在常见的卡(Maxwell和Pascal上, 总是控制kernel在8-16KB之间较好. 你可以用cuobjdump观察一下你的kernel大小和指令数. 第二点则是, 循环unroll会, 不仅仅会带来代码体积增加,也还会带来寄存器使用压力, 例如更多的寄存器使用, 更多的寄存器被交换到local memory(没错, 这反而会增加local memory传输),此时就是昨天说过的章节(launch bounds和maxrregcount)中的内容, 带来反面效果,这里就不重复了. 这是两点主要的坏处.所以综合来说, 一旦你unroll, 好处和坏处都是同时出现的. 但在具体的kernel上, 具体的里面的某个循环被unroll后, 就是谁占上风,哪个更显著, 则不一定. 这个需要用户自己去反复调节,使用不同的参数来控制unroll,以及, 在不同的循环上放置unroll,反复去试验, 从而取得特定有的kernel下的最优效果.(好吧, 其实计算机行业有个传统老说法的,"参数调节是一门玄学") 另外的一个经典的说法则是:"计算机软件工程学, 是玄学中玄学" 所以大家也就别太叫真.请不要问我, "我同事的一个kernel, 展开后性能提升60%",为啥我的kernel, "同样类似的进行unroll展开后性能下降了20%"。。在次真心不能知道,这个真心需要具体问题具体看的..例如一个巨大的循环.循环里面几百行代码,然后循环控制部分只有1行,你说你展开有意义么?基本没有.展开后基本你只会享受到2个基本坏处. 而享受不到4个基本好处.所以这种情况下, 如果一个kernel进行了8处循环的unroll操作, 性能下降了10%,你应当首先去掉这个巨型循环的unroll,保留其他7处,这样反而剩下7处整体可能会提升10%,而不是真的像研究玄学一样的反复闭着眼睛黑蒙.反复随机的试验.
有不明白的地方,请在本文后留言
或者在我们的技术论坛bbs.gpuworld.cn上发帖