#pragma
是程序员和编译器之间通话的重要途径,通过它可以更好地指导编译器对代码进行优化,从某种程度上说也是程序员对编译器的主权体现。
#pragma unroll (n)
是能够利用编译器对循环(for、while、do)进行展开,n表示展开的程度,不加表示完全展开。这是在LLVM的框架下,在GNU下则要是#pragma GCC unroll n
。具体的使用说明可以见GCC的Loop-Specific Pragmas。
\#pragma GCC unroll n
You can use this pragma to control how many times a loop should be unrolled. It must be placed immediately before a for, while or do loop or a #pragma GCC ivdep, and applies only to the loop that follows. n is an integer constant expression specifying the unrolling factor. The values of 0 and 1 block any unrolling of the loop.
循环展开的优点
- 节省人力,不用显示写重复模式的代码
- 消除循环控制成本,尤其是在循环体内代码数量少时
- 扩充指令级并行的潜力,诸如对同一个地址上数据进行读-算-写这样的操作,可以进一步利用寄存器实现优化
- 重复利用硬件架构,压榨每一条指令的能力,如有些RSIC处理器的访存单元会附带一次辅助加法运算用于方便得计算地址,从而释放主计算单元
- 在GPU上可以消除对local memory的访问,通过寄存器的使用消除STL/LDL的指令(利用线程级并行?)
循环展开的顾虑
- 代码体积增加,给ICache造成压力
- 对寄存器的使用造成压力,在GPU上更多的寄存器会被交换到local memory
一个栗子
这里记录X86架构作为目标后端,使用G++(12.2.1 20230201)进行编译的一个两层循环代码示例。check()
检查十进制下的两个三位数是否有重复数字出现。
bool check(unsigned int x,unsigned int y) {
unsigned int x_d[3];
unsigned int y_d[3];
x_d[2] = x/100;
x_d[1] = (x-x_d[2]*100)/10;
x_d[0] = x%10;
y_d[2] = y/100;
y_d[1] = (y-y_d[2]*100)/10;
y_d[0] = y%10;
#pragma GCC unroll 3
for (int i=0; i<3; i++) {
#pragma GCC unroll 3
for (int j=0; j<3; j++) {
if (x_d[i] == y_d[j]) return false;
}
}
return true;
}
使用g++ check.cc -g
编译得到的结果如下。
可以明显看到循环控制的语句,以及两层循环内的jne
跳转和循环主体的cmp
比较。这个情况下编译器只是忠实地翻译了源代码。
#pragma GCC unroll 3
for (int i=0; i<3; i++) {
1260: c7 45 d8 00 00 00 00 movl $0x0,-0x28(%rbp)
1267: eb 39 jmp 12a2 <_Z5checkjj+0x119>
#pragma GCC unroll 3
for (int j=0; j<3; j++) {
1269: c7 45 dc 00 00 00 00 movl $0x0,-0x24(%rbp)
1270: eb 21 jmp 1293 <_Z5checkjj+0x10a>
if (x_d[i] == y_d[j]) return false;
1272: 8b 45 d8 mov -0x28(%rbp),%eax
1275: 48 98 cltq
1277: 8b 54 85 e0 mov -0x20(%rbp,%rax,4),%edx
127b: 8b 45 dc mov -0x24(%rbp),%eax
127e: 48 98 cltq
1280: 8b 44 85 ec mov -0x14(%rbp,%rax,4),%eax
1284: 39 c2 cmp %eax,%edx
1286: 75 07 jne 128f <_Z5checkjj+0x106>
1288: b8 00 00 00 00 mov $0x0,%eax
128d: eb 23 jmp 12b2 <_Z5checkjj+0x129>
for (int j=0; j<3; j++) {
128f: 83 45 dc 01 addl $0x1,-0x24(%rbp)
1293: 83 7d dc 02 cmpl $0x2,-0x24(%rbp)
1297: 0f 9e c0 setle %al
129a: 84 c0 test %al,%al
129c: 75 d4 jne 1272 <_Z5checkjj+0xe9>
for (int i=0; i<3; i++) {
129e: 83 45 d8 01 addl $0x1,-0x28(%rbp)
12a2: 83 7d d8 02 cmpl $0x2,-0x28(%rbp)
12a6: 0f 9e c0 setle %al
12a9: 84 c0 test %al,%al
12ab: 75 bc jne 1269 <_Z5checkjj+0xe0>
}
}
使用g++ check.cc -g -O
编译同时不使用#pragma
进行循环展开,得到的结果如下。
可以看到内层循环因为-O
被优化展开了,而外层循环依然保持。
//#pragma GCC unroll 3
for (int i=0; i<3; i++) {
120f: 48 8d 54 24 0c lea 0xc(%rsp),%rdx
1214: 48 8d 7c 24 18 lea 0x18(%rsp),%rdi
//#pragma GCC unroll 3
for (int j=0; j<3; j++) {
if (x_d[i] == y_d[j]) return false;
1219: 8b 0a mov (%rdx),%ecx
121b: 39 c8 cmp %ecx,%eax
121d: 74 3a je 1259 <_Z5checkjj+0xd0>
121f: 39 ce cmp %ecx,%esi
1221: 74 28 je 124b <_Z5checkjj+0xc2>
1223: 41 39 c8 cmp %ecx,%r8d
1226: 74 2a je 1252 <_Z5checkjj+0xc9>
for (int i=0; i<3; i++) {
1228: 48 83 c2 04 add $0x4,%rdx
122c: 48 39 fa cmp %rdi,%rdx
122f: 75 e8 jne 1219 <_Z5checkjj+0x90>
}
}
使用g++ check.cc -g -O
编译同时用#pragma GCC unroll 1
显式取消循环展开,得到的结果如下。
可以看到,对比没开-O
的结果,在循环控制语句上有部分的优化,即外层循环控制的逻辑放在循环主体的前面了,但是循环没有展开。
#pragma GCC unroll 1
for (int i=0; i<3; i++) {
121b: 48 89 e2 mov %rsp,%rdx
121e: 48 8d 7c 24 0c lea 0xc(%rsp),%rdi
1223: 48 8d 74 24 18 lea 0x18(%rsp),%rsi
1228: eb 09 jmp 1233 <_Z5checkjj+0xaa>
122a: 48 83 c2 04 add $0x4,%rdx
122e: 48 39 fa cmp %rdi,%rdx
1231: 74 1d je 1250 <_Z5checkjj+0xc7>
#pragma GCC unroll 1
for (int j=0; j<3; j++) {
if (x_d[i] == y_d[j]) return false;
1233: 8b 0a mov (%rdx),%ecx
1235: 48 8d 44 24 0c lea 0xc(%rsp),%rax
123a: 3b 08 cmp (%rax),%ecx
123c: 74 0b je 1249 <_Z5checkjj+0xc0>
for (int j=0; j<3; j++) {
123e: 48 83 c0 04 add $0x4,%rax
1242: 48 39 f0 cmp %rsi,%rax
1245: 75 f3 jne 123a <_Z5checkjj+0xb1>
1247: eb e1 jmp 122a <_Z5checkjj+0xa1>
if (x_d[i] == y_d[j]) return false;
1249: b8 00 00 00 00 mov $0x0,%eax
124e: eb 05 jmp 1255 <_Z5checkjj+0xcc>
}
}
使用g++ check.cc -g -O
编译同时加入#pragma GCC unroll 3
进行循环展开,得到的编译结果如下。
可以看到两层循环被完全的展开了,循环控制语句被完全优化掉了。
#pragma GCC unroll 3
for (int i=0; i<3; i++) {
#pragma GCC unroll 3
for (int j=0; j<3; j++) {
if (x_d[i] == y_d[j]) return false;
11e0: 39 ca cmp %ecx,%edx
11e2: 74 3c je 1220 <_Z5checkjj+0xa7>
11e4: 39 f2 cmp %esi,%edx
11e6: 74 3f je 1227 <_Z5checkjj+0xae>
11e8: 44 39 ca cmp %r9d,%edx
11eb: 74 41 je 122e <_Z5checkjj+0xb5>
11ed: 41 39 ca cmp %ecx,%r10d
11f0: 74 43 je 1235 <_Z5checkjj+0xbc>
11f2: 41 39 f2 cmp %esi,%r10d
11f5: 74 45 je 123c <_Z5checkjj+0xc3>
11f7: 45 39 ca cmp %r9d,%r10d
11fa: 74 47 je 1243 <_Z5checkjj+0xca>
x_d[2] = x/100;
11fc: 89 c0 mov %eax,%eax
11fe: 48 69 c0 1f 85 eb 51 imul $0x51eb851f,%rax,%rax
1205: 48 c1 e8 25 shr $0x25,%rax
if (x_d[i] == y_d[j]) return false;
1209: 39 c8 cmp %ecx,%eax
120b: 74 3d je 124a <_Z5checkjj+0xd1>
120d: 44 39 c8 cmp %r9d,%eax
1210: 0f 95 c2 setne %dl
1213: 39 f0 cmp %esi,%eax
1215: b8 00 00 00 00 mov $0x0,%eax
121a: 0f 44 d0 cmove %eax,%edx
}
}
使用g++ check.cc -g -O2
编译同时不使用#pragma
进行展开,得到的结果如下。
就是相对-O
在外层循环上更进一步优化了,可能是来自于其他部分代码的ILP相关优化,但是依然只有内层循环被展开了。
if (x_d[i] == y_d[j]) return false;
13d0: 8b 0a mov (%rdx),%ecx
13d2: 39 c8 cmp %ecx,%eax
13d4: 74 32 je 1408 <_Z5checkjj+0xc8>
13d6: 39 ce cmp %ecx,%esi
13d8: 74 2e je 1408 <_Z5checkjj+0xc8>
13da: 41 39 c8 cmp %ecx,%r8d
13dd: 74 29 je 1408 <_Z5checkjj+0xc8>
for (int i=0; i<3; i++) {
13df: 48 83 c2 04 add $0x4,%rdx
13e3: 48 39 d7 cmp %rdx,%rdi
13e6: 75 e8 jne 13d0 <_Z5checkjj+0x90>
return true;
13e8: b8 01 00 00 00 mov $0x1,%eax
}
13ed: 48 8b 54 24 18 mov 0x18(%rsp),%rdx
13f2: 64 48 2b 14 25 28 00 sub %fs:0x28,%rdx
13f9: 00 00
13fb: 75 0f jne 140c <_Z5checkjj+0xcc>
13fd: 48 83 c4 28 add $0x28,%rsp
1401: c3 ret
1402: 66 0f 1f 44 00 00 nopw 0x0(%rax,%rax,1)
if (x_d[i] == y_d[j]) return false;
1408: 31 c0 xor %eax,%eax
140a: eb e1 jmp 13ed <_Z5checkjj+0xad>
}
结论
#pragma GCC unroll n
只有在开了-O
的情况下才会生效,并且只开-O
或者-O2
会比较保守地只对内层循环进行展开。