Loop Unrolling : May or May Not Make the Program Run Faster
TL;DR
前辈@fcicq 看了我这篇blog后,跟我讲,“你虽然描述清楚了状况,但是不知道这是什么”
@fcicq是一个理论大师,她所说的“什么”就是指某一个专业术语。得益于与@fcicq 的讨论,使得我的理论知识有了一些进步。特此更新。
关于@fcicq,可移步豆瓣: fcicq on Douban
loop unrolling 是将while/for循环的循环主体多排列几次,从而在一次迭代中执行更多的数据操作,他的本质意义是CPU的运算器和内存数据带宽的trade off,即计算机中经典的空间与时间的trade off。具体解释请直接跳到结论那一节。
我们可以手动的修改while/for循环来展开循环体,也可以使用编译器的自动循环展开功能。gcc的-funroll-loops就是用来自动将循环给展开用的。在gcc的手册里,对于-funroll-loops有一句说明,
This option makes code larger, and may or may not make it run faster.
其实这句话并不是很准确,确切的说法是,“This option may improves or reduces the performance.”。
backgroud
在最近我的做的一个项目中,版本做出大幅度升级后,性能下降了30%多,经过各种排查后发现原来是编译器从Gcc-4.1.1升级为Gcc-4.2.1后-funroll-loops的行为发生了变化,使得在循环展开的过程中产生了大量的temp variables,这些temp variables入栈和出栈占用了大量的cpu cycles,从而使得其中一个线程成为bottleneck,性能因此下降了30%多.
why ?
在这篇博客里,来分析下loop unrolling为什么会导致性能下降
如下是我写的一个测试程序用来描述该问题。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
|
对该测试程序的一些注释:
- 该atomic function摘自freebsd source code。
- 使用的编译器是Gcc-4.2.1,平台是MIPS(该CPU是64bit的CPU),ABI是O32.[1]
- 之所以要new_addr的低3bits要为0,是由于sd指令要求地址必须是8字节对齐。
这里就是O32和N32区别的一个体现,O32的寄存器是32bits,所以它的栈空间是4字节对齐,即指针是4字节对齐的;N32的寄存器是64bits,所以它的栈空间是64bits,即指针是8字节对齐。在N32上就不需要做这种处理。 - 由于CPU的寄存器是64bits,所以dsll将寄存器左移32bits后会将寄存器内容移到高32bits,实际上这里是变相在使用N32的特性,即64bit寄存器.
- 为了便于理解反汇编,gcc的优化option使用-O.
- 之所以初始测试条件是TIMES为2,而不是1,是为了防止for循环被编译器给优化掉。
我们编译该程序,然后看反汇编结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
|
可以看出,变量val_hi
和val_lo
分别存储在24(sp)和28(sp)这部分内存空间.
另外还需要注意的变量x。
1 2 3 4 |
|
由于我只是-C来编译,并没有做链接,所以在这里调用random()
这个函数是被编译为了jalr t9
,在MIPS上,函数调用的返回值会放在$v0这个寄存器里面。[2]在这里函数调用的返回值$v0 是直接赋值给了$s0和$v1,而不是放入栈空间,这说明编译器将变量x存储在了寄存器里面而不是内存里面。编译器之所以会这么做,是为了提高性能。变量尽可能的存储在寄存器上,从而减少访问内存所需要消耗的时间。另外再说一句,寄存器数量是有限的,当寄存器无法再存储变量时,就会将临时变量存储到内存上。
然后我们将TIME 变为3,对比看下有无funroll-loops下的差异。
无funroll-loops.
结果证明,跟TIMES为2的情况下相比,反汇编结果一样。此处不再拷贝代码
使用funroll-loops
汇编代码较长,闪花了眼,我在后面只解释重点。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 |
|
可以看到,栈空间一下子由56增加到了168,即增加了112字节这么大的空间!
我们就来分析下这112字节的空间是怎么产生的。
- 从汇编代码来看,变量val_high和val_lo仍然是存储在24(sp)和28(sp)这部分内存空间。但是同时也生成了大量的临时变量。
比如i为1时被展开的循环体:
1 2 3 4 |
|
可以看到至少新增了44(sp)和40(sp)这两个临时变量。
i为2时被展开的循环体:
1 2 3 4 |
|
至少新增了36(sp)和92(sp)这两个临时变量。
i为3时被展开的循环体:
1 2 3 4 |
|
至少新增了60(sp)和124(sp)这两个临时变量。
注意这里不是参数入栈和出栈,因为这里是inline,不是函数调用。增加的这六个临时变量都是因为atomic_write64
这个inline函数里定义了局部变量,他们其实是为了服务于那两个局部变量。
- 再来看变量x
1 2 3 4 |
|
可以看到这里新增加了一个临时变量68(sp)用来存储random()
函数的返回值。
这也是在前面使用4: afa40044 sw a0,68(sp)
来初始化该临时变量的原因。
- 循环展开后i值不同的循环体使用不同的$sn寄存器
用到这些寄存器,就需要在函数开始将该寄存器压栈,然后在函数结束时出栈。
1 2 3 4 5 6 7 8 9 10 11 12 |
|
新增的这些临时变量和对过多寄存器的使用增加了栈空间,而且入栈出栈也很消耗CPU cycles,因为这涉及到读写内存。
问题初步结论
由此我们可以得到一个初步结论,在unroll-loops的过程中,如果产生了额外的内存变量,那么unroll loops就极有可能对性能造成伤害。
- 首先这些额外的临时变量会存储在内存里,执行指令时就会有入栈和出栈,这些多出来的入栈和出栈的指令,就会导致I-cache miss的增大
- 这些变量会占用D-cache空间,从而又导致D-cache miss的增大
关键路径上I-cache和D-cache miss的增大,自然会导致性能的下降, 这是因为不同存储层级的访问速度是不一样的。
存储层级的访问速度从快到慢的排序如下:
- 寄存器/运算器
- L1 D-cache/ L1 I-cache
- L2 cache
- L3 cache (maybe)
- memory
- ..
关于存储层级上的各个延迟, 具体可参考Latency Numbers Every Programmer Should Know
循环展开的意义
以简化的代码来解释。
原始代码
1 2 3 |
|
循环展开后的代码
假设N是4的倍数(这个不是关键)。
1 2 3 |
|
如上代码所示,循环展开后,在一次迭代中完成了4个数组元素的运算。
我们知道,在编译时以常量索引的数组元素通常位于寄存器中,但是以变量索引的数组元素只能放在栈空间。所以a[i]..a[i + 3]这四个数组元素要首先被从栈(内存)中读取到寄存器中,这就带来了更多的寄存器消耗;同时,相比于展开前,展开后的代码需要一次性的消耗4个加法器,即这会消耗更多的运算器。通过消耗更多的寄存器和运算器,节省了对内存的访问次数,从而提升了速度。
更加深入的解释是,对于每份来自内存的数据而言,必须被若干次调用后才能节省内存带宽。即通过增加运算器来提升内存带宽。对于intel CPU而言,以及ARM/MIPS 这种RSIC CPU而言,设计的都是内存带宽过剩但是运算器不足。
对于循环展开而言,如果展开过度,就会导致运算器/寄存器不够用,这些数据和指令就被迫掉入L1 cache,cache的大小不够用,就会继续掉入内存。对于存储层级而言,每次数据被迫掉入下一个存储阶层,带宽大概率要减半或者更多了。
所以展开的次数多少最合适,是有CPU的运算器/寄存器来决定的。
另外,适度展开如果变成SIMD,就会变成数据并行,但是这跟结论无关。
继续来分析gcc的unroll loops的特性。以下的分析基于gcc的源码loop-unroll.c 。
gcc的unroll loops
由于这时TIMES仅为3,gcc可以将其完全展开,即peel_loop_completely,展开方式如下:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
如果将TIMES增大到100,可以发现gcc并不会将循环体展开100次。循环展开多少次最好,在gcc unroll loops的设计者看来也是一个很难的问题,
A great problem is that we don’t have a good way how to determine how many times we should unroll the loop; the experiments I have made showed that this choice may affect performance in order of several %.
因而unroll loops的设计着就定义了一些parameter来由程序员自行定义unroll规则。
这些参数有max-unrolled-insns,max-unroll-times,max-peeled-insns和max-peel-times等。用法大致如—param max-unroll-times=N,如果在使能funroll-loops或者fpeel-loops(如果使能了funroll-loops会同时使能fpeel-loops)后没有设置这些参数,就会使用gcc的默认值,具体参考params.def这个文件。 这些参数的具体含义可以去查阅gcc manual,不再赘述。只说下peel和unroll的区别,从英语语义上来讲,peel是剥离,unroll是摊开,在这里也是这个含义,peel是指从循环中给剥离出去,unroll则是仍然在循环体内。我们可以通过gcc的source code来看下这两者的区别。
gcc unroll-loops的入口函数是rtl_unroll_and_peel_loops
,在这里它会去检查funroll-loops,fpeel-loops,funroll-all-loops这些开关。然后就会调用unroll loops的主要函数体unroll_and_peel_loops
。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
可以看到它首先做的就是尝试peel completely,如果不行,就再去选择unroll/peel策略,最后从最内侧循环开始unroll。 举个简单的例子,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
前两个body就是peel,后面while循环体内就是unroll。
总之就是,使用gcc的unroll需谨慎,如果循环次数在编译阶段就确定的,完全可以手动的展开,而不用gcc的unroll,当然展开的次数也是一个经验值,需要根据具体情况来定。对于运行时才能决定循环次数的,gcc也可以将其展开,展开方法大致如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
|
另外再说一句,unroll loops要在critical path才能体现出价值来,对于non-critical path,就不应该做unroll loops了。这也是gcc的funroll-loops的一个不足之处,它不知道哪里是critical哪里non-critical,对它而言代码是平等的(程序员主动告知编译器的例外,比如likely/unlikely),它会一视同仁的把所有该展开的都展开。也许编程语言日后的发展方向会是,根据运行时的情况来自动的调整可执行文件的布局,从而每执行一次就可以优化一次来让程序越来越快[3].