488

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

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

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或编译它需要 0.44 秒-O3。这些时间是一致获得的,几乎没有噪音(gcc 4.7.2、x86_64 GNU/Linux、Intel Core i5-3320M)。

(更新:我已将所有汇编代码移至GitHub:他们使帖子变得臃肿,并且显然对问题没有什么价值,因为fno-align-*标志具有相同的效果。)

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

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

如果我猜对了,这些是堆栈对齐的填充。根据Why does GCC pad functions with NOPs? 这样做是希望代码运行得更快,但显然这种优化在我的情况下适得其反。

在这种情况下,罪魁祸首是填充吗?为什么以及如何?

它产生的噪音几乎使时序微优化成为不可能。

当我对 C 或 C++ 源代码进行微优化(与堆栈对齐无关)时,如何确保这种意外的幸运/不幸对齐不会受到干扰?


更新:

按照Pascal Cuoq 的回答,我对对齐方式进行了一些修改。通过传递-O2 -fno-align-functions -fno-align-loops给 gcc,所有内容.p2align都从程序集中消失,生成的可执行文件在 0.38 秒内运行。根据gcc 文档

-Os 启用所有 -O2 优化 [但] -Os 禁用以下优化标志:

  -falign-functions  -falign-jumps  -falign-loops
  -falign-labels  -freorder-blocks  -freorder-blocks-and-partition
  -fprefetch-loop-arrays

所以,这看起来像是一个(错误)对齐问题。

我仍然对Marat Dukhan 的回答-march=native中所建议的持怀疑态度。我不相信这不仅仅是干扰这个(错误)对齐问题;它对我的机器完全没有影响。(尽管如此,我赞成他的回答。)


更新 2:

我们可以-Os从图片中取出。通过编译获得以下时间

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

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

  • -S -O2然后手动移动0.37sadd()后的组件work()

  • -O20.44s

在我看来,add()与呼叫站点的距离很重要。我已经尝试过perf,但是 and 的输出perf statperf report我来说意义不大。但是,我只能从中得到一个一致的结果:

-O2

 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-*

 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

 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,而不仅仅是上面给出的那些。缓存未命中非常嘈杂,与执行时间几乎没有相关性。

4

6 回答 6

564

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

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

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

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 Bridge 的 Core i3 上,三个版本的gcc( 4.6.44.7.34.8.1) 生成的二进制文件具有显着不同的性能,但汇编代码只有细微的变化。到目前为止,我对这个事实没有任何解释。

汇编自gcc-4.6.4 -Os(在 0.709 秒内执行):

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 秒内执行):

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 秒内执行):

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
于 2013-10-19T22:24:07.080 回答
204

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


简短的回答:

在这种情况下,罪魁祸首是填充吗?为什么以及如何?

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

我已经向gcc 开发人员提交了一份(伪造的?)错误报告。事实证明,默认行为是“我们默认将循环对齐到 8 个字节,但如果我们不需要填充超过 10 个字节,请尝试将其对齐到 16 个字节。” 显然,在这种特殊情况下和我的机器上,这个默认值并不是最佳选择。Clang 3.4(主干)进行-O3了适当的对齐,并且生成的代码没有显示这种奇怪的行为。

当然,如果进行了不恰当的对齐,事情就会变得更糟。不必要的/错误的对齐只会无缘无故地消耗字节,并可能增加缓存未命中等。

它产生的噪音几乎使时序微优化成为不可能。

当我对 C 或 C++ 源代码进行微优化(与堆栈对齐无关)时,如何确保这种意外的幸运/不幸对齐不会受到干扰?

只需告诉 gcc 进行正确的对齐:

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


长答案:

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

  • XX字节边界add()在中间切开(取决于XX机器)。

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

  • 如果 add()没有对齐。

  • 如果循环未对齐。

前 2 个在Marat Dukhan 友好发布的代码和结果中清晰可见。在这种情况下,gcc-4.8.1 -Os(在 0.994 秒内执行):

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()是切):

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 字节之外:

  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 的倍数,代码对此也不敏感。

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

一个可能的解释是我有对对齐敏感的热点,就像这个例子中的一样。通过弄乱标志(通过-Os而不是-O2),这些热点以一种幸运的方式偶然对齐,并且代码变得更快。它与优化大小无关:热点对齐得更好纯属偶然。从现在开始,我将检查对齐对我的项目的影响。

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

考虑一下:

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

并在一个单独的文件中:

// 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()

就是这样,很容易无意中创建像 OP 中的热点。当然,部分原因是我的错:gcc 是一个出色的编译器。如果把上面编译成:g++ -O2 -flto add.cpp main.cpp,也就是我进行链接时间优化,代码运行时间为0.19s!

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

于 2013-10-24T15:32:19.077 回答
78

我添加这个 post-accept 是为了指出对齐对程序整体性能的影响 - 包括大型程序 - 已经过研究。例如,这篇文章(我相信它的一个版本也出现在 CACM 中)展示了链接顺序和操作系统环境大小的单独变化如何足以显着改变性能。他们将此归因于“热循环”的对齐。

这篇论文的标题是“在没有做任何明显错误的事情的情况下产生错误的数据!” 说由于程序运行环境中几乎无法控制的差异而导致的无意实验偏差可能会使许多基准测试结果毫无意义。

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

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

于 2013-10-27T21:07:00.143 回答
35

我认为您可以获得与您所做的相同的结果:

我抓住了 -O2 的程序集,并将其所有差异合并到了 -Os 的程序集中,除了 .p2align 行:

…通过使用-O2 -falign-functions=1 -falign-jumps=1 -falign-loops=1 -falign-labels=1. 15 年来,我一直在使用这些选项编译所有内容,-O2每次我费心测量时,这些选项都比普通的要快。

此外,对于完全不同的上下文(包括不同的编译器),我注意到情况相似:应该“优化代码大小而不是速度”的选项优化了代码大小和速度。

如果我猜对了,这些是堆栈对齐的填充。

不,这与堆栈无关,默认生成的 NOP 和选项 -falign-*=1 阻止用于代码对齐。

根据Why does GCC pad functions with NOPs? 这样做是希望代码运行得更快,但显然这种优化在我的情况下适得其反。

在这种情况下,罪魁祸首是填充吗?为什么以及如何?

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

于 2013-10-19T21:13:35.697 回答
12

如果您的程序受 CODE L1 缓存的限制,那么优化大小突然开始发挥作用。

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

在您的情况下, -O3 可能为两个缓存行生成足够的代码,但 -Os 适合一个缓存行。

于 2013-10-24T15:48:50.967 回答
7

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

想到的场景是小循环。当分支向后走并且距离不太远时,分支预测正在针对这种情况进行优化,因为所有小循环都是以这种方式完成的。add当您交换和work在生成的代码中的位置或两者的位置略有变化时,相同的规则可能会发挥作用。

也就是说,我不知道如何验证这一点,我只是想让你知道这可能是你想要研究的东西。

于 2013-10-22T19:11:52.017 回答