关于c ++:如果我优化大小而不是速度,为什么GCC会生成15-20%的代码?

Why does GCC generate 15-20% faster code if I optimize for size instead of speed?

我在2009年第一次注意到GCC(至少在我的项目和机器上)倾向于生成明显更快的代码,如果我针对大小(-Os)而不是速度(-O2)或-O3)进行优化,并且我一直在想为什么。

我已经设法创建(相当愚蠢)代码来显示这种令人惊讶的行为,并且足够小,可以发布在这里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const int LOOP_BOUND = 200000000;

__attribute__((noinline))
static int add(const int& x, const int& y) {
    return x + y;
}

__attribute__((noinline))
static int work(int xval, int yval) {
    int sum(0);
    for (int i=0; i<LOOP_BOUND; ++i) {
        int x(xval+sum);
        int y(yval+sum);
        int z = add(x, y);
        sum += z;
    }
    return sum;
}

int main(int , char* argv[]) {
    int result = work(*argv[1], *argv[2]);
    return result;
}

如果我用-Os编译它,执行这个程序需要0.38秒,如果用-O2-O3编译它需要0.44秒。这些时间是一致的,几乎没有噪音(GCC 4.7.2、x86 GNU/Linux、Intel Core i5-3320M)。

(更新:我已经将所有程序集代码都移到了Github上:它们使post膨胀,并且显然对问题增加的值很小,因为fno-align-*标志具有相同的效果。)

这里是使用-Os-O2生成的程序集。

不幸的是,我对装配的理解非常有限,所以我不知道我接下来所做的是否正确:我抓住了-O2的装配,并将它的所有差异合并到了-Os的装配中,除了.p2align行,结果就是这样。这段代码仍在0.38秒内运行,唯一的区别是.p2align的东西。

如果我猜对了,这些是用于堆栈对齐的填充。为什么GCC PAD与NOP一起工作?这样做是为了希望代码运行得更快,但显然在我的例子中,这种优化适得其反。

这起案件的罪魁祸首是填充物吗?为什么?

它产生的噪声使得时间微优化变得不可能。

当我在C或C++源代码上做微优化(与堆栈对齐无关)时,我如何确保这种偶然的幸运/不吉利对齐不干扰?

更新:

按照帕斯卡·库克的回答,我稍微调整了一下路线。通过将-O2 -fno-align-functions -fno-align-loops传递给gcc,所有.p2align都从程序集中消失,生成的可执行文件在0.38s内运行。根据gcc文件:

-Os enables all -O2 optimizations [but] -Os disables the following optimization flags:

1
2
3
  -falign-functions  -falign-jumps  -falign-loops <br/>
  -falign-labels  -freorder-blocks  -freorder-blocks-and-partition <br/>
  -fprefetch-loop-arrays <br/>

所以,这似乎是一个(错误的)对齐问题。

正如玛拉特杜可汗的回答所暗示的那样,我仍然对江户十一〔十五〕持怀疑态度。我不相信它不仅会干扰这个(错误)对齐问题;它对我的机器绝对没有影响。(不过,我对他的回答投了反对票。)

更新2:

我们可以把-Os从照片中去掉。以下时间是通过编译

  • -O2 -fno-omit-frame-pointer0.37秒

  • -O2 -fno-align-functions -fno-align-loops0.37秒

  • -S -O2然后在work()0.37s后手动移动add()的总成。

  • -O20.44秒

在我看来,add()到呼叫站点的距离非常重要。我试过perf,但perf statperf report的输出对我来说没什么意义。但是,我只能从中得到一个一致的结果:

-O2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
 602,312,864 stalled-cycles-frontend   #    0.00% frontend cycles idle
       3,318 cache-misses
 0.432703993 seconds time elapsed
 [...]
 81.23%  a.out  a.out              [.] work(int, int)
 18.50%  a.out  a.out              [.] add(int const&, int const&) [clone .isra.0]
 [...]
       |   __attribute__((noinline))
       |   static int add(const int& x, const int& y) {
       |       return x + y;
100.00 |     lea    (%rdi,%rsi,1),%eax
       |   }
       |   ? retq
[...]
       |            int z = add(x, y);
  1.93 |    ? callq  add(int const&, int const&) [clone .isra.0]
       |            sum += z;
 79.79 |      add    %eax,%ebx

对于fno-align-*

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
 604,072,552 stalled-cycles-frontend   #    0.00% frontend cycles idle
       9,508 cache-misses
 0.375681928 seconds time elapsed
 [...]
 82.58%  a.out  a.out              [.] work(int, int)
 16.83%  a.out  a.out              [.] add(int const&, int const&) [clone .isra.0]
 [...]
       |   __attribute__((noinline))
       |   static int add(const int& x, const int& y) {
       |       return x + y;
 51.59 |     lea    (%rdi,%rsi,1),%eax
       |   }
[...]
       |    __attribute__((noinline))
       |    static int work(int xval, int yval) {
       |        int sum(0);
       |        for (int i=0; i<LOOP_BOUND; ++i) {
       |            int x(xval+sum);
  8.20 |      lea    0x0(%r13,%rbx,1),%edi
       |            int y(yval+sum);
       |            int z = add(x, y);
 35.34 |    ? callq  add(int const&, int const&) [clone .isra.0]
       |            sum += z;
 39.48 |      add    %eax,%ebx
       |    }

对于-fno-omit-frame-pointer

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
 404,625,639 stalled-cycles-frontend   #    0.00% frontend cycles idle
      10,514 cache-misses
 0.375445137 seconds time elapsed
 [...]
 75.35%  a.out  a.out              [.] add(int const&, int const&) [clone .isra.0]                                                                                     |
 24.46%  a.out  a.out              [.] work(int, int)
 [...]
       |   __attribute__((noinline))
       |   static int add(const int& x, const int& y) {
 18.67 |     push   %rbp
       |       return x + y;
 18.49 |     lea    (%rdi,%rsi,1),%eax
       |   const int LOOP_BOUND = 200000000;
       |
       |   __attribute__((noinline))
       |   static int add(const int& x, const int& y) {
       |     mov    %rsp,%rbp
       |       return x + y;
       |   }
 12.71 |     pop    %rbp
       |   ? retq
 [...]
       |            int z = add(x, y);
       |    ? callq  add(int const&, int const&) [clone .isra.0]
       |            sum += z;
 29.83 |      add    %eax,%ebx

看来我们打电话给add()的速度慢了。

我已经检查了perf -e可以在我的机器上吐出的所有信息,而不仅仅是上面给出的数据。

对于同一个可执行文件,stalled-cycles-frontend显示了与执行时间的线性相关性;我没有注意到任何其他与执行时间相关的东西。(比较不同可执行文件的stalled-cycles-frontend对我来说没有意义。)

我把缓存未命中作为第一条评论包括在内。我检查了所有可以由perf在我的机器上测量的缓存未命中,而不仅仅是上面给出的缓存未命中。缓存未命中非常嘈杂,与执行时间几乎没有相关性。


默认情况下,编译器针对"平均"处理器进行优化。由于不同的处理器支持不同的指令序列,因此由-O2启用的编译器优化可能有益于普通处理器,但会降低特定处理器的性能(同样适用于-Os)。如果您在不同的处理器上尝试相同的示例,您会发现在其中一些处理器上受益于-O2,而另一些则更利于-Os优化。

以下是几个处理器上的time ./test 0 0的结果(报告的用户时间):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Processor (System-on-Chip)             Compiler   Time (-O2)  Time (-Os)  Fastest
AMD Opteron 8350                       gcc-4.8.1    0.704s      0.896s      -O2
AMD FX-6300                            gcc-4.8.1    0.392s      0.340s      -Os
AMD E2-1800                            gcc-4.7.2    0.740s      0.832s      -O2
Intel Xeon E5405                       gcc-4.8.1    0.603s      0.804s      -O2
Intel Xeon E5-2603                     gcc-4.4.7    1.121s      1.122s       -
Intel Core i3-3217U                    gcc-4.6.4    0.709s      0.709s       -
Intel Core i3-3217U                    gcc-4.7.3    0.708s      0.822s      -O2
Intel Core i3-3217U                    gcc-4.8.1    0.708s      0.944s      -O2
Intel Core i7-4770K                    gcc-4.8.1    0.296s      0.288s      -Os
Intel Atom 330                         gcc-4.8.1    2.003s      2.007s      -O2
ARM 1176JZF-S (Broadcom BCM2835)       gcc-4.6.3    3.470s      3.480s      -O2
ARM Cortex-A8 (TI OMAP DM3730)         gcc-4.6.3    2.727s      2.727s       -
ARM Cortex-A9 (TI OMAP 4460)           gcc-4.6.3    1.648s      1.648s       -
ARM Cortex-A9 (Samsung Exynos 4412)    gcc-4.6.3    1.250s      1.250s       -
ARM Cortex-A15 (Samsung Exynos 5250)   gcc-4.7.2    0.700s      0.700s       -
Qualcomm Snapdragon APQ8060A           gcc-4.8       1.53s       1.52s      -Os

在某些情况下,您可以通过要求gcc为特定处理器进行优化(使用选项-mtune=native-march=native)来减轻不利优化的影响:

1
2
3
4
5
Processor            Compiler   Time (-O2 -mtune=native) Time (-Os -mtune=native)
AMD FX-6300          gcc-4.8.1         0.340s                   0.340s
AMD E2-1800          gcc-4.7.2         0.740s                   0.832s
Intel Xeon E5405     gcc-4.8.1         0.603s                   0.803s
Intel Core i7-4770K  gcc-4.8.1         0.296s                   0.288s

更新:在基于Ivy桥的CoreI3上,三个版本的gcc(4.6.44.7.34.8.1)生成性能显著不同的二进制文件,但汇编代码只有细微的变化。到目前为止,我还没有对这个事实的解释。

gcc-4.6.4 -Os装配(以0.709秒执行):

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
00000000004004d2 <_ZL3addRKiS0_.isra.0>:
  4004d2:       8d 04 37                lea    eax,[rdi+rsi*1]
  4004d5:       c3                      ret

00000000004004d6 <_ZL4workii>:
  4004d6:       41 55                   push   r13
  4004d8:       41 89 fd                mov    r13d,edi
  4004db:       41 54                   push   r12
  4004dd:       41 89 f4                mov    r12d,esi
  4004e0:       55                      push   rbp
  4004e1:       bd 00 c2 eb 0b          mov    ebp,0xbebc200
  4004e6:       53                      push   rbx
  4004e7:       31 db                   xor    ebx,ebx
  4004e9:       41 8d 34 1c             lea    esi,[r12+rbx*1]
  4004ed:       41 8d 7c 1d 00          lea    edi,[r13+rbx*1+0x0]
  4004f2:       e8 db ff ff ff          call   4004d2 <_ZL3addRKiS0_.isra.0>
  4004f7:       01 c3                   add    ebx,eax
  4004f9:       ff cd                   dec    ebp
  4004fb:       75 ec                   jne    4004e9 <_ZL4workii+0x13>
  4004fd:       89 d8                   mov    eax,ebx
  4004ff:       5b                      pop    rbx
  400500:       5d                      pop    rbp
  400501:       41 5c                   pop    r12
  400503:       41 5d                   pop    r13
  400505:       c3                      ret

gcc-4.7.3 -Os装配(以0.822秒执行):

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
00000000004004fa <_ZL3addRKiS0_.isra.0>:
  4004fa:       8d 04 37                lea    eax,[rdi+rsi*1]
  4004fd:       c3                      ret

00000000004004fe <_ZL4workii>:
  4004fe:       41 55                   push   r13
  400500:       41 89 f5                mov    r13d,esi
  400503:       41 54                   push   r12
  400505:       41 89 fc                mov    r12d,edi
  400508:       55                      push   rbp
  400509:       bd 00 c2 eb 0b          mov    ebp,0xbebc200
  40050e:       53                      push   rbx
  40050f:       31 db                   xor    ebx,ebx
  400511:       41 8d 74 1d 00          lea    esi,[r13+rbx*1+0x0]
  400516:       41 8d 3c 1c             lea    edi,[r12+rbx*1]
  40051a:       e8 db ff ff ff          call   4004fa <_ZL3addRKiS0_.isra.0>
  40051f:       01 c3                   add    ebx,eax
  400521:       ff cd                   dec    ebp
  400523:       75 ec                   jne    400511 <_ZL4workii+0x13>
  400525:       89 d8                   mov    eax,ebx
  400527:       5b                      pop    rbx
  400528:       5d                      pop    rbp
  400529:       41 5c                   pop    r12
  40052b:       41 5d                   pop    r13
  40052d:       c3                      ret

gcc-4.8.1 -Os装配(以0.994秒执行):

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
00000000004004fd <_ZL3addRKiS0_.isra.0>:
  4004fd:       8d 04 37                lea    eax,[rdi+rsi*1]
  400500:       c3                      ret

0000000000400501 <_ZL4workii>:
  400501:       41 55                   push   r13
  400503:       41 89 f5                mov    r13d,esi
  400506:       41 54                   push   r12
  400508:       41 89 fc                mov    r12d,edi
  40050b:       55                      push   rbp
  40050c:       bd 00 c2 eb 0b          mov    ebp,0xbebc200
  400511:       53                      push   rbx
  400512:       31 db                   xor    ebx,ebx
  400514:       41 8d 74 1d 00          lea    esi,[r13+rbx*1+0x0]
  400519:       41 8d 3c 1c             lea    edi,[r12+rbx*1]
  40051d:       e8 db ff ff ff          call   4004fd <_ZL3addRKiS0_.isra.0>
  400522:       01 c3                   add    ebx,eax
  400524:       ff cd                   dec    ebp
  400526:       75 ec                   jne    400514 <_ZL4workii+0x13>
  400528:       89 d8                   mov    eax,ebx
  40052a:       5b                      pop    rbx
  40052b:       5d                      pop    rbp
  40052c:       41 5c                   pop    r12
  40052e:       41 5d                   pop    r13
  400530:       c3                      ret


我的同事帮我找到了一个合理的答案。他注意到256字节边界的重要性。他没有在这里注册,并鼓励我自己发布答案(并带走所有的名声)。

简短回答:

Is it the padding that is the culprit in this case? Why and how?

这一切归根结底是一致的。对齐会对性能产生重大影响,这就是为什么我们首先使用-falign-*标志的原因。

我提交了一份(假的?)向GCC开发人员报告错误。结果显示,默认行为是"我们默认将循环与8字节对齐,但如果不需要填充超过10个字节,则尝试将其与16字节对齐。"显然,在这种特定情况下,在我的计算机上,此默认不是最佳选择。clang 3.4(trunk)与-O3进行了适当的对齐,生成的代码没有显示出这种奇怪的行为。

当然,如果做了不适当的调整,事情就会变得更糟。不必要/不正确的对齐会毫无原因地占用字节,并可能增加缓存未命中等。

The noise it makes pretty much makes timing micro-optimizations
impossible.

How can I make sure that such accidental lucky / unlucky alignments
are not interfering when I do micro-optimizations (unrelated to stack
alignment) on C or C++ source codes?

只需告诉GCC进行正确的校准:

g++ -O2 -falign-functions=16 -falign-loops=16

长回答:

如果出现以下情况,代码将运行较慢:

  • 一个XX字节边界在中间切断add()(XX与机器有关)。

  • 如果对add()的调用必须跳过XX字节边界,并且目标没有对齐。

  • 如果add()未对齐。

  • 如果循环没有对齐。

前2个是美丽可见的代码和结果,马拉特杜可汗亲切张贴。在这种情况下,gcc-4.8.1 -Os(以0.994秒执行):

1
2
3
00000000004004fd <_ZL3addRKiS0_.isra.0>:
  4004fd:       8d 04 37                lea    eax,[rdi+rsi*1]
  400500:       c3

一个256字节的边界在中间向右剪切add(),既不对齐add(),也不对齐循环。惊喜,惊喜,这是最慢的情况!

gcc-4.7.3 -Os的情况下(以0.822秒执行),256字节的边界只会切入一个冷段(但循环和add()都不会被切断):

1
2
3
4
5
6
7
00000000004004fa <_ZL3addRKiS0_.isra.0>:
  4004fa:       8d 04 37                lea    eax,[rdi+rsi*1]
  4004fd:       c3                      ret

[...]

  40051a:       e8 db ff ff ff          call   4004fa <_ZL3addRKiS0_.isra.0>

没有对齐,对add()的调用必须跳过256字节边界。这是第二慢的代码。

如果gcc-4.6.4 -Os(以0.709秒执行),尽管没有对齐,但是对add()的调用不必跳过256字节边界,目标正好在32字节之外:

1
2
3
4
  4004f2:       e8 db ff ff ff          call   4004d2 <_ZL3addRKiS0_.isra.0>
  4004f7:       01 c3                   add    ebx,eax
  4004f9:       ff cd                   dec    ebp
  4004fb:       75 ec                   jne    4004e9 <_ZL4workii+0x13>

这是这三种方法中最快的一种。为什么256字节的边界在他的机器上是特殊的,我将由他来决定。我没有这样的处理器。

现在,在我的机器上,我没有得到这个256字节的边界效果。我的机器上只有功能和循环对齐。如果我通过g++ -O2 -falign-functions=16 -falign-loops=16,那么一切都恢复正常:我总是得到最快的情况,时间不再对-fno-omit-frame-pointer标志敏感。我可以通过g++ -O2 -falign-functions=32 -falign-loops=32或16的任何倍数,代码也不对此敏感。

I first noticed in 2009 that gcc (at least on my projects and on my
machines) have the tendency to generate noticeably faster code if I
optimize for size (-Os) instead of speed (-O2 or -O3) and I have been
wondering ever since why.

一个可能的解释是,我有对对齐敏感的热点,就像本例中的热点一样。通过干扰标志(通过-Os而不是-O2),这些热点意外地以幸运的方式对齐,代码变得更快。这与优化大小无关:这些完全是由于意外,热点得到了更好的对齐。从现在开始,我将检查对齐对我的项目的影响。

哦,还有一件事。这样的热点是如何出现的,如示例中所示的热点?像add()这样一个小函数的内联怎么会失败呢?

考虑一下:

1
2
3
4
// add.cpp
int add(const int& x, const int& y) {
    return x + y;
}

在单独的文件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// main.cpp
int add(const int& x, const int& y);

const int LOOP_BOUND = 200000000;

__attribute__((noinline))
static int work(int xval, int yval) {
    int sum(0);
    for (int i=0; i<LOOP_BOUND; ++i) {
        int x(xval+sum);
        int y(yval+sum);
        int z = add(x, y);
        sum += z;
    }
    return sum;
}

int main(int , char* argv[]) {
    int result = work(*argv[1], *argv[2]);
    return result;
}

编制为:g++ -O2 add.cpp main.cpp

&GCC不会内联add()

仅此而已,很容易不经意地创建热点,比如操作中的热点。当然,这部分是我的错:gcc是一个优秀的编译器。如果把上面的编译为:g++ -O2 -flto add.cpp main.cpp,也就是说,如果我执行链接时间优化,代码将在0.19秒内运行!

(内联在OP中被人为禁用,因此OP中的代码慢了2倍)。


我在补充这篇文章accept的目的是指出,已经研究了对齐对程序(包括大型程序)整体性能的影响。例如,本文(我相信这一版本也出现在CACM中)展示了链接顺序和操作系统环境大小的变化如何足以显著地改变性能。他们把这归因于"热循环"的对齐。

本文的题目是"不做任何明显错误的事情而产生错误的数据!"说由于程序运行环境中几乎不可控制的差异导致的无意的实验偏差可能会使许多基准测试结果变得毫无意义。

我认为你在同一个观察中遇到了不同的角度。

对于性能关键的代码,对于那些在安装或运行时评估环境并在不同优化版本的关键例程中选择本地最佳的系统来说,这是一个很好的理由。


我认为你可以得到与你所做的相同的结果:

I grabbed the assembly for -O2 and merged all its differences into the assembly for -Os except the .p2align lines:

…使用-O2 -falign-functions=1 -falign-jumps=1 -falign-loops=1 -falign-labels=1。15年来,我一直在用这些选项编译所有的东西,这些选项比普通的-O2更快,每次我费心去测量。

另外,对于完全不同的上下文(包括不同的编译器),我注意到情况是类似的:应该"优化代码大小而不是速度"的选项针对代码大小和速度进行优化。

If I guess correctly, these are paddings for stack alignment.

不,这与堆栈无关,默认情况下生成的nop和选项-falign-*=1 prevent用于代码对齐。

According to Why does GCC pad functions with NOPs? it is done in the hope that the code will run faster but apparently this optimization backfired in my case.

Is it the padding that is the culprit in this case? Why and how?

很可能是填充物造成的。之所以认为填充是必要的并且在某些情况下是有用的,是因为代码通常是以16字节的行来获取的(有关详细信息,请参阅Agner Fog的优化资源,这些信息因处理器的型号而异)。在16字节的边界上对齐一个函数、循环或标签意味着统计上增加了包含该函数或循环所需的行数更少的可能性。显然,这会适得其反,因为这些nop会降低代码密度,从而降低缓存效率。在循环和标签的情况下,NOP甚至可能需要执行一次(当执行通常到达循环/标签时,而不是从跳转)。


如果程序受代码l1缓存的限制,那么对大小的优化就会突然开始付出代价。

当我上次检查时,编译器还不够聪明,在所有情况下都无法解决这个问题。

在您的例子中,-o3可能为两条缓存线生成足够的代码,但-os适合于一条缓存线。


我决不是这方面的专家,但我似乎记得现代处理器在分支预测方面相当敏感。用于预测分支的算法基于代码的几个属性,包括目标的距离和方向,或者至少在我编写汇编程序代码的时候。

想到的场景是小循环。当分支向后移动并且距离不太远时,分支预测正在为这种情况进行优化,因为所有的小循环都是这样做的。当您在生成的代码中交换addwork的位置,或者当两者的位置发生轻微变化时,可能会使用相同的规则。

也就是说,我不知道如何验证,我只是想让你知道这可能是你想调查的事情。