我总是想知道为什么编译器无法弄清楚对人眼来说显而易见的简单事物.他们做了很多简单的优化,但从来没有一点甚至有点复杂.例如,此代码在我的计算机上大约需要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 StringBuilder,甚至更好地直接用结果字符串替换它,因为它总是相同的.其次,它没有意识到我根本没有使用该字符串,因此整个循环可能被丢弃!
为什么在这么多人力进入快速编译器之后,他们仍然是如此相对愚蠢?
编辑:当然这些是永远不应该在任何地方使用的愚蠢的例子.但每当我必须将一个漂亮且非常易读的代码重写为不可读的代码以便编译器很开心并生成快速代码时,我想知道为什么编译器或其他一些自动化工具不能为我做这项工作.
在我看来,我不相信编译器的工作是修复什么,老实说,编码错误.你非常明确地告诉编译器你想要第一个循环执行.它与以下相同:
x = 0 sleep 6 // Let's assume this is defined somewhere. print x
我不希望编译器删除我的sleep
语句只是因为它什么也没做.您可能会认为sleep语句是对延迟的明确请求,而您的示例则不是.但是,您将允许编译器对您的代码应该做什么做出非常高级的决定,并且我认为这是一件坏事.
代码和处理它的编译器是工具,如果你想有效地使用它们,你需要成为一个工具.有多少12"电锯会拒绝尝试减少30英尺的树木?如果检测到混凝土墙,有多少钻头会自动切换到锤子模式?
没有,我怀疑,这是因为将产品设计成产品的成本一开始就是可怕的.但是,更重要的是,如果你不知道自己在做什么,就不应该使用钻头或电锯.例如:如果你不知道回扣是什么(新手取下手臂的一种非常简单的方法),请远离电锯,直到你这样做.
我只是允许编译器建议改进,但我宁愿自己保持控制.编译器不应单方面决定循环是不必要的.
例如,我已经在嵌入式系统中完成了时序循环,其中CPU的时钟速度确切已知但没有可靠的时序器件可用.在这种情况下,您可以精确计算给定循环将花费多长时间,并使用它来控制事件发生的频率.如果编译器(或那种情况下的汇编程序)决定我的循环没用并且优化它不存在,那就行不通.
话虽如此,让我留下一个VAX FORTRAN编译器的旧故事,该编译器正在经历性能基准测试,并且发现它比最接近的竞争对手快了许多个数量级.
事实证明编译器注意到基准测试循环的结果没有在其他任何地方使用,并优化了循环到遗忘.
哦,我不知道.有时编译器很聪明.考虑以下C程序:
#include/* 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()生成代码,使用递归调用来计算值.但是在-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
令人印象深刻 递归调用(甚至不是尾递归)已经完全消除,因此因子现在使用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
看到-O1
线?gcc是在编译时预先计算factorial(10).它甚至不调用factorial().难以置信.我的帽子是GCC开发团队的.
当然,所有通常的免责声明都适用,这只是一个玩具示例,过早优化是所有邪恶的根源等等,但它说明编译器通常比你想象的更聪明.如果你认为你可以手工做得更好,那你几乎肯定是错的.
(改编自我博客上的帖子.)
从C/C++的角度来讲:
您的第一个示例将由大多数编译器优化.如果来自Sun的java编译器真的执行了这个循环,那就是编译器的错误,但是说实话,任何1990 C,C++或Fortran编译器都完全消除了这样的循环.
您的第二个示例无法在大多数语言中进行优化,因为内存分配是将字符串连接在一起的副作用.如果编译器会优化代码,则内存分配模式会发生变化,这可能会导致程序员试图避免的影响.内存碎片和相关问题是嵌入式程序员每天仍然面临的问题.
总的来说,我对编译器最近可以做的优化感到满意.
编译器设计为可预测的.这可能会让他们不时看起来很愚蠢,但那没关系.编译器编写者的目标是
您应该能够查看代码并对其性能做出合理的预测.
代码中的微小变化不应导致性能的显着差异.
如果一个小的改变看起来像程序员那样应该提高性能,那么它至少应该不会降低性能(除非硬件中出现令人惊讶的事情).
所有这些标准都不利于仅适用于极端情况的"魔术"优化.
您的两个示例都在循环中更新了变量,但未在其他地方使用.除非您使用某种可以将死代码消除与其他优化(如复制传播或常量传播)相结合的框架,否则这种情况实际上很难获得.对于简单的数据流优化器,变量看起来并不死.要理解为什么这个问题很难,请参阅POPL 2002中的Lerner,Grove和Chambers的论文,它使用了这个例子,并解释了为什么它很难.
HotSpot JIT编译器只会优化已运行一段时间的代码.当您的代码很热时,循环已经启动,JIT编译器必须等到下次进入该方法时才能寻找优化循环的方法.如果多次调用该方法,可能会看到更好的性能.
HotSpot常见问题解答中包含了这个问题,在"我编写一个简单的循环来计算一个简单的操作并且它很慢.我做错了什么?".
真的吗?为什么有人会编写这样的真实代码?恕我直言,代码,而不是编译器是这里的"愚蠢"实体.我很高兴编译器编写者不会浪费他们的时间来尝试优化这样的东西.
编辑/澄清: 我知道问题中的代码是作为一个例子,但这证明了我的观点:你要么必须尝试,要么完全无能为力地编写这样的低效代码.握住我们的手并不是编译器的工作所以我们不会编写可怕的代码.作为编写代码的人员,我们有责任充分了解我们的工具,以便高效,清晰地编写代码.
好吧,我只能谈论C++,因为我完全是Java初学者.在C++中,编译器可以自由地忽略标准所放置的任何语言要求,只要可观察行为是- 如果编译器实际模拟标准所放置的所有规则.可观察行为定义为对易失性数据的任何读写和对库函数的调用.考虑一下:
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就会产生一次,因为代码的行为就像 - 如果循环从未发生过,并且不涉及易失性数据,也不涉及库函数这可能会导致副作用.现在考虑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次.根据对第一次执行的观察,它再次编译它.但它恰好与字节码大小相同两次,表明循环仍然存在.
正如另一个程序员所示,对于某些情况,某些死循环的执行时间甚至会增加,以用于随后编译的代码.他报告了一个可以在这里阅读的错误,截至2008年10月24日.
在您的第一个示例中,这是一个仅在值为零时才有效的优化.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.使用较新语言难以解决的一件大事就是修补猴子.现在+=
可以有副作用,这意味着如果我们优化它,它可能是错误的(例如,添加功能+=
,打印出当前值将意味着完全不同的程序).
但它再次归结为同样的事情:你需要寻找太多的情况以确保没有执行可能改变最终程序状态的副作用.
只需要花一点时间就可以更容易,并确保您所写的内容是您真正希望计算机执行的操作.:)
编译器一般都很聪明.
您必须考虑的是,他们必须考虑每个可能的异常或情况,其中优化或重新分解代码可能会导致不必要的副作用.
诸如线程程序,指针别名,动态链接代码和副作用(系统调用/内存分配)等等,使得正式的重构非常困难.
即使您的示例很简单,仍然可能需要考虑一些困难的情况.
至于你的StringBuilder参数,那不是编译器工作来选择要用于哪些数据结构.
如果你想要更强大的优化,可以转向更强类型的语言,比如fortran或haskell,在这些语言中,编译器可以获得更多信息.
大多数课程教授编译器/优化(甚至是一般性的),使人们对如何使普通的正式推广优化而非黑客攻击特定案例感到高兴,这是一个非常困难的问题.
我认为你低估了确保一段代码不会影响另一段代码的工作量.只需对示例进行一些小改动x,i和s都可以指向相同的内存.一旦其中一个变量成为指针,就很难分辨哪些代码可能有副作用,具体取决于指向什么.
此外,我认为编制编制者的人宁愿花时间进行优化,这对人类来说也不容易.