4

可能每个人都使用某种优化开关(在 gcc 的情况下,我相信最常见的是-O2 )。

但是 gcc(以及其他编译器,如 VS、Clang)在存在这些选项的情况下真正做了什么?

当然没有明确的答案,因为它很大程度上取决于平台、编译器版本等。但是,如果可能的话,我想收集一套“经验法则”。我什么时候应该考虑一些技巧来加速代码,什么时候应该把工作留给编译器?

例如,对于不同的优化级别,编译器在这种(有点人为的......)情况下会走多远:

1) sin(3.141592) // 会在编译时进行评估,还是我应该考虑一个查找表来加速计算?

2) int a = 0; a = exp(18), cos(1.57), 2; // 编译器是否会计算 exp 和 cos,尽管不需要,因为表达式的值等于 2?

3)

for (size_t i = 0; i < 10; ++i) {
  int a = 10 + i;
}

// 编译器会跳过整个循环,因为它没有可见的副作用吗?

也许你可以想到其他的例子。

4

3 回答 3

6

如果您想知道编译器的作用,最好的办法是查看编译器文档。对于优化,您可以查看LLVM 的 Analysis and Transform Passes例如。

1) sin(3.141592) // 是否会在编译时进行评估?

大概。IEEE 浮点计算有非常精确的语义。顺便说一句,如果您在运行时更改处理器标志,这可能会令人惊讶。

2) int a = 0; a = exp(18), cos(1.57), 2;

这取决于:

  • 函数expcos是否内联
  • 如果不是,它们是否正确注释(因此编译器知道它们没有副作用)

对于取自 C 或 C++ 标准库的函数,应正确识别/注释它们。

至于消除计算:

  • -adce: 积极的死代码消除
  • -dce: 死代码消除
  • -die: 死指令消除
  • -dse: 死店消除

编译器喜欢寻找无用的代码 :)

3)

2)实际相似。不使用存储的结果并且表达式没有副作用。

  • -loop-deletion: 删除死循环

最后:什么不对编译器进行测试?

#include <math.h>
#include <stdio.h>

int main(int argc, char* argv[]) {
  double d = sin(3.141592);
  printf("%f", d);

  int a = 0; a = (exp(18), cos(1.57), 2); /* need parentheses here */
  printf("%d", a);

  for (size_t i = 0; i < 10; ++i) {
    int a = 10 + i;
  }

  return 0;
}

Clang 在编译过程中已经尝试提供帮助:

12814_0.c:8:28: warning: expression result unused [-Wunused-value]
  int a = 0; a = (exp(18), cos(1.57), 2);
                           ^~~ ~~~~
12814_0.c:12:9: warning: unused variable 'a' [-Wunused-variable]
    int a = 10 + i;
        ^

以及发出的代码(LLVM IR):

@.str = private unnamed_addr constant [3 x i8] c"%f\00", align 1
@.str1 = private unnamed_addr constant [3 x i8] c"%d\00", align 1

define i32 @main(i32 %argc, i8** nocapture %argv) nounwind uwtable {
  %1 = tail call i32 (i8*, ...)* @printf(i8* getelementptr inbounds ([3 x i8]* @.str, i64 0, i64 0), double 0x3EA5EE4B2791A46F) nounwind
  %2 = tail call i32 (i8*, ...)* @printf(i8* getelementptr inbounds ([3 x i8]* @.str1, i64 0, i64 0), i32 2) nounwind
  ret i32 0
}

我们注意到:

  • 正如预测的那样,sin计算已在编译时解决
  • 正如预测的那样,exp并且cos已被完全剥离。
  • 正如预测的那样,循环也被剥离了。

如果您想更深入地研究编译器优化,我鼓励您:

  • 学习阅读 IR(这非常容易,真的,组装起来要简单得多)
  • 使用 LLVM 试用页面来测试您的假设
于 2012-09-07T11:27:44.670 回答
1

编译器有许多优化通道。每个优化过程都负责一些小的优化。例如,您可能有一个在编译时计算算术表达式的过程(例如,您可以将 5MB 表示为 5 * (1024*1024) 而不会受到惩罚)。另一个传递内联函数。另一个搜索无法访问的代码并将其杀死。等等。

编译器的开发人员然后决定他们希望以何种顺序执行这些传递中的哪些。例如,假设您有以下代码:

int foo(int a, int b) {
  return a + b;
}

void bar() {
  if (foo(1, 2) > 5)
    std::cout << "foo is large\n";
}

如果您对此运行死代码消除,则不会发生任何事情。同样,如果您运行表达式缩减,则不会发生任何事情。但是内联器可能会认为 foo 小到可以内联,所以它用函数体替换 bar 中的调用,替换参数:

void bar() {
  if (1 + 2 > 5)
    std::cout << "foo is large\n";
}

如果现在运行表达式归约它将首先确定 1 + 2 为 3,然后确定 3 > 5 为假。所以你得到:

void bar() {
  if (false)
    std::cout << "foo is large\n";
}

现在死代码消除将看到一个 if(false) 并杀死它,所以结果是:

void bar() {
}

但是现在bar突然变得非常小了,之前它更大更复杂。因此,如果您再次运行内联程序,它将能够将 bar 内联到其调用者中。这可能会带来更多的优化机会,等等。

对于编译器开发人员来说,这是编译时间和生成代码质量之间的权衡。他们根据启发式、测试和经验决定要运行的优化器序列。但是由于一种尺寸并不适合所有尺寸,因此它们会暴露一些旋钮来调整它。gcc 和 clang 的主要旋钮是 -O 选项系列。-O1 运行一个简短的优化器列表;-O3 运行更长的列表,其中包含更昂贵的优化器,并且更频繁地重复传递。

除了决定运行哪些优化器之外,这些选项还可以调整各种通道使用的内部启发式方法。例如,内联器通常有很多参数来决定何时值得内联一个函数。通过 -O3,只要有可能提高性能,这些参数就会更倾向于内联函数;传递 -Os,参数只会导致非常小的函数(或可证明只调用一次的函数)被内联,因为其他任何东西都会增加可执行文件的大小。

于 2012-09-07T12:13:24.523 回答
0

编译器会执行您无法想到的各种优化。尤其是 C++ 编译器。

他们做一些事情,比如展开循环、使函数内联、消除死代码、用一条指令替换多条指令等等。

我可以给出的一条建议是:在 C/C++ 编译器中,您可以相信它们会执行很多优化。

看看[1]。

[1] http://en.wikipedia.org/wiki/Compiler_optimization

于 2012-09-07T11:12:57.650 回答