#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会比较保守地只对内层循环进行展开。