为什么编译器如此愚蠢?

时间:2009-01-02 01:01:37

标签: performance language-agnostic compiler-construction

我总是想知道为什么编译器无法弄清楚对人眼来说很明显的简单事物。他们做了很多简单的优化,但从来没有一点甚至有点复杂。例如,此代码在我的计算机上大约需要6秒才能打印零值(使用java 1.6):

int x = 0;
for (int i = 0; i < 100 * 1000 * 1000 * 1000; ++i) {
    x += x + x + x + x + x;
}
System.out.println(x);

很明显,x永远不会改变,所以无论你多久给自己添加0,它就会保持为零。因此编译器理论上可以用System.out.println(0)替换它。

甚至更好,这需要23秒:

public int slow() {
    String s = "x";
    for (int i = 0; i < 100000; ++i) {
        s += "x";
    }
    return 10;
}

首先,编译器可能会注意到我实际上正在创建一个100000“x”的字符串s,因此它可以自动使用s StringBuilder,甚至更好地直接用结果字符串替换它,因为它始终是相同的。其次,它没有认识到我根本没有使用字符串,因此可以丢弃整个循环!

为什么在这么多人力进入快速编译器之后,他们仍然是如此相对愚蠢?

编辑:当然这些都是永远不应该在任何地方使用的愚蠢的例子。但每当我必须将一个漂亮且非常易读的代码重写为不可读的代码以便编译器很高兴并生成快速代码时,我想知道为什么编译器或其他一些自动化工具不能为我做这项工作。

29 个答案:

答案 0 :(得分:112)

在我看来,我不相信编译器的工作就是解决什么,老实说,编码错误。你已经非常明确地告诉编译器你想要执行第一个循环。它与:

相同
x = 0
sleep 6 // Let's assume this is defined somewhere.
print x

我不希望编译器删除我的sleep语句只是因为它什么也没做。您可能会认为sleep语句是对延迟的明确请求,而您的示例则不是。但是,您将允许编译器对您的代码应该做什么做出非常高级的决定,并且我认为这是一件坏事。

代码和处理它的编译器是工具,如果你想有效地使用它们,你需要成为一个工具。有多少12“电锯会拒绝尝试减少30英尺的树木?如果检测到混凝土墙,有多少钻头会自动切换到锤子模式?

没有,我怀疑,这是因为将产品设计成产品的成本一开始就是可怕的。但是,更重要的是,如果你不知道自己在做什么,就不应该使用钻头或电锯。例如:如果您不知道回扣是什么(新手取下手臂的一种非常简单的方法),请远离电锯,直到您这样做。

我只是允许编译器建议改进,但我宁愿自己维护控件。编译器不应单方面决定循环是不必要的。

例如,我已经在嵌入式系统中完成了时序循环,其中CPU的时钟速度确切已知,但没有可靠的时序器件可用。在这种情况下,您可以精确计算给定循环将花费多长时间,并使用它来控制事件发生的频率。如果编译器(或那种情况下的汇编程序)认为我的循环没用并且优化它不存在,那就不行了。

话虽如此,让我留下一个关于VAX FORTRAN编译器的旧故事,该编译器正在经历性能基准测试,并且发现它比其最接近的竞争对手快许多几个数量级

事实证明编译器注意到基准测试循环的结果没有在其他地方使用,并且优化了循环被遗忘。

答案 1 :(得分:107)

哦,我不知道。有时编译器很聪明。考虑以下C程序:

#include <stdio.h>  /* printf() */

int factorial(int n) {
   return n == 0 ? 1 : n * factorial(n - 1);
}

int main() {
   int n = 10;

   printf("factorial(%d) = %d\n", n, factorial(n));

   return 0;
}

在我的GCC版本(Debian测试中的4.3.2)上,在没有优化的情况下进行编译时,或-O1,它会为您生成factorial()的代码d期望,使用递归调用来计算值。但是在-O2上,它做了一些有趣的事情:它编译成一个紧密的循环:

    factorial:
   .LFB13:
           testl   %edi, %edi
           movl    $1, %eax
           je  .L3
           .p2align 4,,10
           .p2align 3
   .L4:
           imull   %edi, %eax
           subl    $1, %edi
           jne .L4
   .L3:
           rep
           ret

非常令人印象深刻。递归调用(甚至不是尾递归)已经完全消除,因此factorial现在使用O(1)堆栈空​​间而不是O(N)。虽然我对x86汇编只有非常肤浅的知识(在这种情况下实际上是AMD64,但我认为上面没有使用任何AMD64扩展),我怀疑你是否可以手工编写更好的版本。但真正引起我注意的是它在-O3上生成的代码。阶乘的实现保持不变。但main()已更改:

    main:
   .LFB14:
           subq    $8, %rsp
   .LCFI0:
           movl    $3628800, %edx
           movl    $10, %esi
           movl    $.LC0, %edi
           xorl    %eax, %eax
           call    printf
           xorl    %eax, %eax
           addq    $8, %rsp
           ret

请参阅movl $3628800, %edx行? gcc在编译时预先计算factorial(10)它甚至不会调用factorial()。难以置信。我的帽子是GCC开发团队的。

当然,所有通常的免责声明都适用,这只是一个玩具示例,过早优化是所有邪恶的根源等等,但它说明编译器通常比你想象的更聪明。如果你认为你可以手工做得更好,那你几乎肯定是错的。

(改编自posting on my blog。)

答案 2 :(得分:46)

从C / C ++的角度讲:

大多数编译器都会优化您的第一个示例。如果来自Sun的java编译器真的执行了这个循环,那就是编译器的错误,但是我认为任何1990 C,C ++或Fortran编译器都完全消除了这样的循环。

您的第二个示例无法在大多数语言中进行优化,因为内存分配是将字符串连接在一起的副作用。如果编译器会优化代码,则内存分配模式会发生变化,这可能会导致程序员试图避免的影响。内存碎片和相关问题是嵌入式程序员每天仍然面临的问题。

总的来说,我对编译器最近可以做的优化感到满意。

答案 3 :(得分:23)

编译器设计为可预测。这可能会让他们不时看起来很愚蠢,但那没关系。编译器编写者的目标是

  • 您应该能够查看代码并对其效果做出合理的预测。

  • 代码中的细微变化不应导致性能的显着差异。

  • 如果对程序员进行一些小改动就像它应该提高性能一样,它至少应该不会降低性能(除非硬件中出现令人惊讶的事情)。

所有这些标准都不利于仅适用于极端情况的“魔法”优化。


您的两个示例都有一个变量在循环中更新但未在其他地方使用。除非您使用某种可以将死代码消除与其他优化(如复制传播或常量传播)相结合的框架,否则这种情况实际上很难获得。对于简单数据流优化器,该变量看起来并不死。要理解为什么这个问题很难,请参阅paper by Lerner, Grove, and Chambers in POPL 2002,它使用了这个例子,并解释了为什么它很难。

答案 4 :(得分:16)

HotSpot JIT编译器只会优化已运行一段时间的代码。当您的代码很热时,循环已经启动,JIT编译器必须等到下次进入该方法时才能寻找优化循环的方法。如果多次调用该方法,您可能会看到更好的性能。

这在HotSpot FAQ中有所提及,问题是“我写一个简单的循环来计算一个简单的操作,而且速度很慢。我做错了什么?”。

答案 5 :(得分:14)

真的?为什么有人会编写这样的真实代码?恕我直言,代码,而不是编译器是这里的“愚蠢”实体。我非常高兴编译器编写者不会浪费时间来尝试优化这样的东西。

修改/澄清: 我知道问题中的代码是作为一个例子,但这证明了我的观点:你要么必须尝试,要么完全无能为力地编写这样的低效代码。握住我们的手并不是编译器的工作所以我们不会编写可怕的代码。作为编写代码的人,我们有责任充分了解我们的工具,以便有效和清晰地编写。

答案 6 :(得分:12)

好吧,我只能谈论C ++,因为我完全是Java初学者。在C ++中,编译器可以自由地忽略标准所放置的任何语言要求,只要可观察行为是 as-if 编译器实际模拟所有放置的规则按标准。可观察行为定义为对易失性数据的任何读写和对库函数的调用。考虑一下:

extern int x; // defined elsewhere
for (int i = 0; i < 100 * 1000 * 1000 * 1000; ++i) {
    x += x + x + x + x + x;
}
return x;

允许C ++编译器优化掉那段代码,只需将一个适当的值添加到该循环一次的x,因为代码行为 as as if 循环从未发生过,并且没有可能导致副作用的易失性数据或库函数。现在考虑volatile变量:

extern volatile int x; // defined elsewhere
for (int i = 0; i < 100 * 1000 * 1000 * 1000; ++i) {
    x += x + x + x + x + x;
}
return x;

编译器允许再进行相同的优化,因为它无法证明写入x导致的副作用不会影响程序的可观察行为。毕竟,x可以被设置为由某些硬件设备观看的存储器单元,该存储器单元将在每次写入时触发。


说到Java,我已经测试了你的循环,并且发生了GNU Java编译器(gcj)花费了过多的时间来完成你的循环(它根本没有完成并且我杀了它) 。我启用了优化标记(-O2),它发生了它立即打印出0

[js@HOST2 java]$ gcj --main=Optimize -O2 Optimize.java
[js@HOST2 java]$ ./a.out
0
[js@HOST2 java]$

也许这个观察在这个帖子中有用吗?为什么gcj会这么快?嗯,肯定的一个原因是gcj编译成机器代码,因此它不可能根据代码的运行时行为优化代码。它将所有强大功能集中在一起,并尝试在编译时尽可能地进行优化。但是,虚拟机可以编译Just in Time,因为此java输出显示此代码:

class Optimize {
    private static int doIt() {
        int x = 0;
        for (int i = 0; i < 100 * 1000 * 1000 * 1000; ++i) {
            x += x + x + x + x + x;
        }
        return x;
    }
    public static void main(String[] args) {
        for(int i=0;i<5;i++) {
            doIt();
        }
    }
}

java -XX:+PrintCompilation Optimize的输出:

1       java.lang.String::hashCode (60 bytes)
1%      Optimize::doIt @ 4 (30 bytes)
2       Optimize::doIt (30 bytes)

正如我们所见,JIT编译doIt函数2次。根据对第一次执行的观察,它再次编译它。但它恰好与字节码大小相同两次,表明循环仍然存在。

正如另一位程序员所示,某些死循环的执行时间甚至会因某些情况而增加,以用于随后编译的代码。他报告了一个可以阅读here的错误,截至2008年10月24日。

答案 7 :(得分:9)

在您的第一个示例中,它是一种仅在值为零时才有效的优化。编译器中需要查找这个很少见的子句的额外if语句可能不值得(因为它必须在每个变量上检查这个)。那么,这个怎么样:

int x = 1;
int y = 1;
int z = x - y;
for (int i = 0; i < 100 * 1000 * 1000 * 1000; ++i) {
    z += z + z + z + z + z;
}
System.out.println(z);

这显然仍然是一回事,但现在我们必须在编译器中编写一个额外的案例。只有无数种方式可以最终为零而​​不值得编码,我想你可以说,如果你想拥有其中一种,你可能也可以拥有它们。

有些优化会照顾你发布的第二个例子,但我想我已经在函数式语言中看到了更多,而不是Java。使用较新语言的难点是monkey-patching。现在+=可以有side-effect,这意味着如果我们优化它,它可能是错误的(例如,向+=添加打印出当前值的功能将意味着完全不同的程序。)< / p>

但它再次归结为同样的事情:你需要寻找太多的案例以确保没有任何副作用可能会改变最终程序的状态。

更容易采取额外的时刻,并确保你正在写的是你真正想要的计算机。 :)

答案 8 :(得分:7)

编译器一般非常聪明。

您必须考虑的是,他们必须考虑到每个可能的异常或情况,其中优化或重新分解代码可能会导致不必要的副作用。

线程程序,指针别名,动态链接代码和副作用(系统调用/内存分配)等等,使得正式推理重构变得非常困难。

即使您的示例很简单,仍有可能需要考虑的情况。

对于您的StringBuilder参数,选择要使用的数据结构不是编译器工作。

如果您想要更强大的优化,请转到更强类型的语言,例如fortran或haskell,在这些语言中,编译器可以获得更多信息。

大多数课程教授编译器/优化(甚至是一般的)都会让人产生一种欣赏感,即如何使普通的正式推广优化而不是黑客攻击特定情况是一个非常困难的问题。

答案 9 :(得分:6)

我认为你低估了确保一段代码不影响另一段代码所需的工作量。只需对示例进行一些小改动x,i和s都可以指向相同的内存。一旦其中一个变量成为指针,就很难判断哪些代码可能有副作用,具体取决于指向什么。

此外,我认为编制编制者的人宁愿花时间进行优化,这对人类来说也不容易。

答案 10 :(得分:3)

因为我们还没有。您可以很容易地问,“为什么我仍然需要编写程序...为什么我不能只需要提交需求文档并让计算机为我编写应用程序?”

编译器编写者花时间处理这些小事情,因为这些是应用程序员往往会错过的东西。

另外,他们不能假设太多(也许你的循环是某种贫民窟时间延迟或什么的)?

答案 11 :(得分:2)

这是编译器编写者和程序员之间永恒的军备竞赛。

非人为的例子效果很好 - 大多数编译器确实优化了明显无用的代码。

Contrived examines 总是残留编译器。证明,如果需要,任何程序员都比任何程序更聪明。

将来,你需要比你在这里发布的更加人为的例子。

答案 12 :(得分:2)

正如其他人已经充分解决了你问题的第一部分,我会尝试解决第二部分,即“自动使用StringBuilder”。

有几个很好的理由没有按照你的建议去做,但实际中最大的因素可能是优化器在实际的源代码被消化之后运行很长时间。忘记了。优化器通常在生成的字节代码(或汇编,三个地址代码,机器代码等)上运行,或者在解析代码时生成的抽象语法树上运行。优化器通常不知道运行时库(或任何库),而是在指令级操作(即低级控制流和寄存器分配)。

其次,随着库的发展(特别是在Java中)比语言快得多,跟上它们并知道什么以及哪些其他库组件可能更适合该任务,这将是一项艰巨的任务。也可能是不可能的,因为这个提议的优化器必须精确地理解你的每个可用库组件的意图和意图,并以某种方式找到它们之间的映射。

最后,正如其他人所说(我认为),编译器/优化器编写者可以合理地假设编写输入代码的程序员并没有脑死亡。当其他更普遍的优化比比皆是时,花费大量精力来处理像这样的特殊情况将是浪费时间。此外,正如其他人也提到的那样,看似脑残的代码可以有一个实际目的(自旋锁,在系统级产量之前等待繁忙等),并且编译器必须遵守程序员要求的内容(如果它在语法和语义上都是有效的。)

答案 13 :(得分:1)

可以进行严格别名优化的编译器将优化第一个示例。请参阅here

第二个例子无法优化,因为这里最慢的部分是内存分配/重新分配,而operator + =被重新定义为执行内存填充的函数。字符串的不同实现使用不同的分配策略。

当我做s + =“s”时,我自己也更愿意拥有malloc(100000)而不是千万malloc(100);但是现在这个东西超出了编译器的范围,必须由人们进行优化。这就是D语言试图通过引入pure functions来解决的问题。

正如其他答案中所述,perl在不到一秒的时间内完成了第二个例子,因为它会分配比请求更多的内存,以防以后需要更多的内存。

答案 14 :(得分:1)

你编译发布代码了吗?我认为一个好的编译器在你的第二个例子中检测到字符串从未被使用过去除了整个循环。

答案 15 :(得分:1)

在发布模式VS 2010 C ++中,这不需要任何时间来运行。然而,调试模式是另一个故事。

#include <stdio.h>
int main()
{
    int x = 0;
    for (int i = 0; i < 100 * 1000 * 1000 * 1000; ++i) {
        x += x + x + x + x + x;
    }
    printf("%d", x);
}

答案 16 :(得分:1)

我从未见过消除死代码的重点。为什么程序员写它?如果您要对死代码执行某些操作,请将其声明为编译器错误!它几乎肯定意味着程序员犯了一个错误 - 对于少数情况它没有,编译器指令使用变量将是正确的答案。如果我将死代码放在例程中,我希望它被执行 - 我可能正计划在调试器中检查结果。

编译器可以做一些好事的情况是拉出循环不变量。有时清晰度表示在循环中对计算进行编码并让编译器将这些东西拉出来会很好。

答案 17 :(得分:1)

在编译为JVM字节码时,优化这样的事情几乎被认为是不好的做法。 Sun的javac确实有一些基本的优化,scalacgroovyc等也是如此。简而言之,任何真正特定语言的东西都可以在编译器中得到优化。然而,像这样的事情显然是如此设计,以至于语言无关,这些事情将完全脱离政策。

这样做的原因是它允许HotSpot具有更加一致的字节码及其模式视图。如果编译器开始使用边缘情况,则会降低VM优化一般情况的能力,这在编译时可能并不明显。 Steve Yeggie喜欢对此有所了解:在运行时由一个聪明的虚拟机执行优化通常更容易。他甚至声称HotSpot剥夺了javac的优化。虽然我不知道这是否属实,但我不会感到惊讶。

总结:针对VM的编译器具有非常不同的标准集,特别是在优化领域和适当时。不要责怪编译器编写者将工作留给功能更强大的JVM。正如在这个主题上多次指出的那样,针对本机架构的现代编译器(如gcc系列)非常非常聪明,通过一些非常智能的优化产生了极其快速的代码。

答案 18 :(得分:1)

你抱怨的主要是'为什么Java编译器如此愚蠢',因为大多数其他语言编译器都更加智能。

Java编译器愚蠢的原因是历史性的。首先,原始的java实现是基于解释器的,并且性能是不重要的。其次,许多原始的Java基准测试都存在优化问题。我记得有一个基准看起来很像你的第二个例子。不幸的是,如果编译器优化了循环,那么当基准测试试图将基线数除以计算其性能得分的经过时间时,基准测试将获得除以零的异常。所以在编写优化的java编译器时,你必须非常小心,不要优化一些东西,因为人们会声称你的编译器坏了。

答案 19 :(得分:1)

绝对优化是一个不可判定的问题,也就是说,没有图灵机(因而也没有计算机程序)可以产生任何给定程序的最佳版本。

一些简单的优化可以(事实上,已经完成),但是,在你给出的例子中......

  1. 要检测您的第一个程序始终打印为零,编译器必须检测到x保持不变,尽管所有循环迭代。你怎么能解释(我知道,这不是最好的词,但我不能想出另一个)到编译器?

  2. 编译器如何知道StringBuilder是没有任何引用的工作的正确工具?

  3. 在实际应用程序中,如果效率在应用程序的一部分中至关重要,则必须使用C语言等低级语言编写。(哈哈,说真的,我写了这个? )

答案 20 :(得分:1)

实际上,Java应该在第二个示例中使用字符串构建器。

尝试优化这些示例的基本问题是,这样做需要定理证明。这意味着编译器需要构建一个数学证明,证明你的代码实际上会做什么。这根本不是一项小任务。事实上,能够证明所有代码确实具有效果等同于停止问题。

当然,你可以提出一些简单的例子,但是琐碎的例子是无限的。你总是可以想到别的东西,所以没有办法把它们都抓住了。

当然,可以证明某些代码没有任何影响,如您的示例中所示。您想要做的是让编译器优化P时间内可以证明未使用的每个问题。

但无论如何,这是一项大量的工作,并没有让你这么多。人们花费大量时间试图找出防止程序中存在错误的方法,并且类似Java和Scala中的类型系统试图防止错误,但是现在没有人使用类型系统来做出关于执行时间的陈述据我所知。

你可能想看一下Haskel,我认为它有最先进的理论证明的东西,虽然我不确定。我自己也不知道。

答案 21 :(得分:0)

前提:我在大学学习编译器。

javac编译器非常愚蠢,并且完全不执行优化,因为它依赖于java运行时来完成它们。运行时将捕获该事物并对其进行优化,但只有在函数执行几千次后才会捕获它。

如果您使用更好的编译器(如gcc)启用优化,它将优化您的代码,因为它是一个非常明显的优化。

答案 22 :(得分:0)

我讨厌提出这么一个老问题(无论如何,我是怎么来到这里的?),但我认为从Commodore 64时代起,这可能是一种阻碍。

在20世纪80年代早期,一切都在一个固定的时钟上运行。没有Turbo Boosting,并且总是为具有特定处理器和特定内存等的特定系统创建代码。在Commodore BASIC中,实现delay的标准方法看起来很像:

10 FOR X = 1 TO 1000
20 NEXT : REM 1-SECOND DELAY

(实际上,在实践中,它更像10FORX=1TO1000:NEXT,但你知道我的意思。)

如果他们要优化这一点,它就会破坏一切 - 任何事情都不会被定时。我不知道有任何例子,但我确信在编译语言的历史中散布了很多像这样的小东西,这些东西无法对事物进行优化。

诚然,今天这些非优化不是。但是,编译器开发人员可能有一些不言而喻的规则,即不优化这样的事情。我不知道。

很高兴你的代码有所优化,与C64上的代码不同。使用最有效的BASIC循环在C64上显示位图最多可能需要60秒;因此,大多数游戏等都是用机器语言编写的。用机器语言编写游戏并不好玩。

只是我的想法。

答案 23 :(得分:0)

编译器的工作是优化代码 的功能,而不是 代码确实有用

编写程序时,您正在告诉计算机该怎么做。如果编译器将您的代码更改为执行除您要求的以外的其他功能,那将不是一个很好的编译器!编写x += x + x + x + x + x时,是在明确告诉计算机您希望将x设置为自身的6倍。编译器可能会很好地优化如何做到这一点(例如,将x乘以6而不是进行重复加法),但是无论如何它仍然会以某种方式计算该值。

如果您不想做某事,请不要告诉别人去做。

答案 24 :(得分:0)

因为编译器编写者尝试为重要的事情添加优化(我希望)并且用* Stone基准测量(我担心)。

有数以万计的其他可能的代码片段像你的一样,什么都不做,可以通过增加编译器编写器的工作来优化,但几乎没有遇到过。

令我感到尴尬的是,即使在今天,大多数编译器都生成代码来检查switchValue是否大于255,以便对无符号字符进行密集或几乎完整的切换。这为大多数字节码解释器的内循环增加了2条指令。

答案 25 :(得分:0)

这是程序代码与功能代码的一个例子。

您已经详细介绍了编译器要遵循的过程,因此优化将基于详细的过程,并将最小化任何副作用或不优化它不会按预期执行的操作。这样可以更容易地进行调试。

如果你输入你想要的功能描述,例如。然后,SQL为编译器提供了广泛的优化选项。

也许某种类型的代码分析能够在运行时找到这种类型的问题或分析,但是你会想要将源代码更改为更合理的东西。

答案 26 :(得分:-1)

编译器和我们一样聪明。我不知道有太多的程序员会费心去编写一个能够检查你所使用的构造的编译器。大多数人专注于提高性能的更典型方法。

有一天我们可能会拥有可以实际学习和发展的软件,包括编译器。当那一天到来时,也许所有程序员都会失业。

答案 27 :(得分:-1)

你的两个例子的含义是没有意义的,没用的,只是为了欺骗编译器。

编译器无法(也不应该)查看方法,循环或程序的含义。这就是你进入图片的地方。无论多么愚蠢,您都可以为某个功能/含义创建一个方法。简单问题也是如此 或极端复杂的程序。

在您的情况下,编译器可能会优化它,因为它“认为”它应该被优化 另一种方式,但为什么留在那里?

极端的其他情况。我们有一个编译Windows的智能编译器。要编译的代码量。但如果它很聪明,它可以归结为3行代码......

"starting windows"
"enjoy freecell/solitaire"
"shutting down windows"

其余代码已过时,因为它从未被使用,触摸,访问过。 我们真的想要吗?

答案 28 :(得分:-2)

它迫使你(程序员)思考你在写什么。强制编译器为你做的工作对任何人都没有帮助:它使编译器变得更加复杂(而且速度更慢!),这会使你愚蠢而不太注意你的代码。