
[英]Why do compilers use XMM registers for raw/std arrays but not vectors?
[英]Why are compilers so stupid?
我总是想知道为什么编译器无法弄清楚人眼显而易见的简单事物。 他们做了很多简单的优化,但从来没有做过一点点复杂的事情。 例如,此代码在我的计算机上大约需要 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 代替,或者甚至更好地直接用结果字符串替换它,因为它总是相同的。 其次,它不承认我实际上根本不使用字符串,因此可以丢弃整个循环!
为什么在大量人力投入到快速编译器之后,它们仍然相对愚蠢?
编辑:当然,这些都是愚蠢的例子,不应该在任何地方使用。 但是每当我必须将漂亮且非常易读的代码改写为不可读的代码以便编译器满意并生成快速代码时,我想知道为什么编译器或其他一些自动化工具不能为我完成这项工作。
在我看来,老实说,我不相信编译器的工作是修复糟糕的编码。 您已经非常明确地告诉编译器您希望执行第一个循环。 它与以下内容相同:
x = 0
sleep 6 // Let's assume this is defined somewhere.
print x
我不希望编译器仅仅因为它什么都没做就删除我的sleep
语句。 您可能会争辩说 sleep 语句是对延迟的明确请求,而您的示例则不是。 但是随后您将允许编译器对您的代码应该做什么做出非常高级的决定,我认为这是一件坏事。
代码和处理它的编译器都是工具,如果你想有效地使用它们,你需要成为一个工具匠。 有多少 12 英寸的链锯会拒绝砍伐 30 英寸的树? 如果检测到混凝土墙,有多少钻头会自动切换到锤击模式?
没有,我怀疑,这是因为将其设计到产品中的成本一开始就非常可怕。 但是,更重要的是,如果您不知道自己在做什么,就不应该使用电钻或链锯。 例如:如果您不知道回扣是什么(对于新手来说,这是一种非常简单的脱手方法),请远离链锯,直到您知道为止。
我完全赞成允许编译器提出改进建议,但我宁愿自己维护控制权。 不应该由编译器单方面决定循环是不必要的。
例如,我在嵌入式系统中完成了计时循环,其中 CPU 的时钟速度是准确知道的,但没有可靠的计时设备可用。 在这种情况下,您可以精确计算给定循环需要多长时间,并使用它来控制事情发生的频率。 如果编译器(或在那种情况下的汇编器)认为我的循环无用并将其优化不存在,那将不起作用。
话虽如此,让我给您留下一个关于 VAX FORTRAN 编译器的老故事,该编译器正在进行性能基准测试,结果发现它比最接近的竞争对手快许多数量级。
事实证明,编译器注意到基准循环的结果没有在其他任何地方使用,并将循环优化为遗忘。
哦,我不知道。 有时编译器非常聪明。 考虑以下 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()
生成代码,使用递归调用来计算值。 但是在-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
看到movl $3628800, %edx
行了吗? gcc 在编译时预先计算factorial(10)
。 它甚至不调用factorial()
。 难以置信。 我向 GCC 开发团队致敬。
当然,所有常见的免责声明都适用,这只是一个玩具示例,过早优化是万恶之源等等,但它说明编译器通常比您想象的更聪明。 如果你认为你可以手工做得更好,那你几乎肯定是错的。
从C/C++的角度来说:
您的第一个示例将被大多数编译器优化。 如果 Sun 的 java 编译器真的执行了这个循环,那是编译器的错,但我保证任何 1990 年后的 C、C++ 或 Fortran 编译器都完全消除了这样的循环。
您的第二个示例无法在大多数语言中进行优化,因为内存分配是将字符串连接在一起的副作用。 如果编译器优化代码,内存分配模式就会改变,这可能会导致程序员试图避免的影响。 内存碎片和相关问题是嵌入式程序员每天仍然面临的问题。
总的来说,我对编译器现在可以做的优化感到满意。
编译器被设计为可预测的。 这可能会让他们不时看起来很愚蠢,但这没关系。 编译器作者的目标是
您应该能够查看您的代码并对其性能做出合理的预测。
代码中的小改动不应导致性能的巨大差异。
如果程序员认为一个小的改变应该提高性能,它至少应该不会降低性能(除非硬件中发生了令人惊讶的事情)。
所有这些标准都阻碍了仅适用于极端情况的“魔法”优化。
您的两个示例都有一个在循环中更新但未在其他地方使用的变量。 这种情况实际上很难接受,除非您使用某种可以将死代码消除与其他优化(如复制传播或常量传播)相结合的框架。 对于一个简单的数据流优化器,该变量看起来并没有死。 要了解为什么这个问题很难,请参阅Lerner、Grove 和 Chambers 在 POPL 2002 中的论文,其中使用了这个例子并解释了为什么它很难。
HotSpot JIT 编译器只会优化已经运行了一段时间的代码。 当您的代码很热时,循环已经开始,JIT 编译器必须等到下一次进入该方法时才能寻找优化循环的方法。 如果多次调用该方法,您可能会看到更好的性能。
这在HotSpot FAQ 中的问题“我编写了一个简单的循环来为一个简单的操作计时,而且速度很慢。我做错了什么?”的问题下。
认真的? 为什么会有人写这样的真实世界的代码? 恕我直言,这里的“愚蠢”实体是代码,而不是编译器。 我很高兴编译器编写者不会浪费时间尝试优化类似的东西。
编辑/澄清:我知道问题中的代码只是作为一个例子,但这只是证明了我的观点:你要么必须尝试,要么相当无知来编写这样的极其低效的代码。 握住我们的手不是编译器的工作,所以我们不会编写糟糕的代码。 作为编写代码的人,我们有责任充分了解我们的工具,以便高效、清晰地编写代码。
好吧,我只能说 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 编译成机器代码,因此它不可能根据代码的运行时行为优化该代码。 它集所有的力量于一身,并试图在编译时尽可能多地优化。 但是,虚拟机可以及时编译代码,正如此代码的 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)
正如我们所见,它对 doIt 函数进行了 2 次 JIT 编译。 基于对第一次执行的观察,它第二次编译它。 但它恰好有两次与字节码相同的大小,表明循环仍然存在。
正如另一位程序员所示,对于随后编译的代码,某些死循环的执行时间甚至会在某些情况下增加。 他报告了一个可以在此处阅读的错误,截至 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 稍作更改,就可以全部指向相同的内存。 一旦变量之一是指针,就很难根据指向什么来判断哪些代码可能产生副作用。
此外,我认为为编译器编程的人宁愿花时间进行人类不容易做到的优化。
因为我们还没有到那里。 您可以很容易地问,“为什么我仍然需要编写程序......为什么我不能只输入需求文档并让计算机为我编写应用程序?”
编译器编写者将时间花在小事情上,因为应用程序程序员往往会错过这些类型的事情。
此外,他们不能假设太多(也许你的循环是某种贫民窟时间延迟或什么)?
这是编译器编写者和程序员之间永恒的军备竞赛。
非人为设计的示例效果很好——大多数编译器确实优化掉了明显无用的代码。
人为的检查将始终难倒编译器。 证明,如果需要的话,任何程序员都比任何程序都聪明。
将来,您将需要比您在此处发布的示例更多的人为示例。
由于其他人已经充分解决了您问题的第一部分,我将尝试解决第二部分,即“自动使用 StringBuilder 代替”。
有几个很好的理由不执行您的建议,但实践中最大的因素可能是优化器在实际源代码被消化和遗忘后运行很长时间。 优化器通常要么对生成的字节码(或汇编、三地址码、机器码等)进行操作,要么对解析代码产生的抽象语法树进行操作。 优化器通常对运行时库(或任何库)一无所知,而是在指令级别(即低级别控制流和寄存器分配)进行操作。
其次,随着库的发展(尤其是在 Java 中)比语言快得多,跟上它们并了解什么弃用什么以及哪些其他库组件可能更适合该任务将是一项艰巨的任务。 也可能是不可能的,因为这个提议的优化器必须准确理解您的意图和每个可用库组件的意图,并以某种方式找到它们之间的映射。
最后,正如其他人所说(我认为),编译器/优化器作者可以合理地假设编写输入代码的程序员不是脑死亡。 当其他更通用的优化比比皆是时,将大量精力投入到像这些愚蠢的特殊情况上将是浪费时间。 此外,正如其他人也提到的,看似脑残的代码可能有实际用途(自旋锁、系统级产量之前的忙等待等),编译器必须尊重程序员的要求(如果它在语法和语义上都是有效的)。
在发布模式 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);
}
你编译发布代码了吗? 我认为一个好的编译器会在您的第二个示例中检测到该字符串从未被使用并删除整个循环。
实际上,Java 应该在您的第二个示例中使用字符串构建器。
.试图优化这些示例的基本问题是,这样做需要。 这意味着编译器需要构建一个数学证明来证明你的代码实际上会做什么。 这根本不是一项小任务。 事实上,能够证明所有代码确实有效果就相当于停机问题。
当然,你可以想出琐碎的例子,但琐碎的例子的数量是无限的。 你总是可以想到别的东西,所以没有办法把它们都抓住。
当然,如您的示例所示,某些代码可能会被证明没有任何效果。 您想要做的是让编译器优化掉所有可以证明在 P 时间内未使用的问题。
但无论如何,这是一项繁重的工作,并不会让您受益匪浅。 人们花费大量时间试图找出防止程序中存在错误的方法,而 Java 和 Scala 中的类型系统正在尝试防止错误,但现在没有人使用类型系统来声明执行时间,据我所知。
您可能想研究 Haskel,我认为它具有最先进的理论证明材料,尽管我不确定。 我自己也不知道。
绝对优化是一个不可判定的问题,这意味着没有图灵机(因此也没有计算机程序)可以产生任何给定程序的最佳版本。
可以(并且实际上已经)完成一些简单的优化,但是,在您提供的示例中...
为了检测您的第一个程序总是打印零,编译器必须检测到 x 尽管进行了所有循环迭代,但仍保持不变。 您如何向编译器解释(我知道,这不是最好的词,但我想不出另一个词)?
编译器如何在没有任何参考的情况下知道 StringBuilder 是适合这项工作的工具?
在现实世界的应用程序中,如果应用程序的某个部分的效率至关重要,那么它必须用像 C 这样的低级语言编写。 (哈哈,说真的,我写的?)
大多数情况下,您抱怨的是“为什么 Java 编译器如此愚蠢”,因为大多数其他语言编译器要聪明得多。
Java 编译器愚蠢的原因是历史性的。 首先,原始的 java 实现是基于解释器的,性能被认为是不重要的。 其次,许多原始的 Java 基准测试都存在优化问题。 我记得一个基准看起来很像你的第二个例子。 不幸的是,如果编译器优化了循环,当它尝试将基线数字除以计算其性能得分所用的时间时,基准测试将得到除以零异常。 因此,在编写优化的 Java 编译器时,您必须非常小心,不要优化某些东西,因为人们会声称您的编译器已损坏。
在编译为 JVM 字节码时优化这样的东西几乎被认为是不好的做法。 Sun的javac
确实有一些基本的优化一样, scalac
, groovyc
,等等,总之,凡是忠实地真实专用语言可以编译器中得到优化。 然而,像这样的事情显然是人为的,以至于语言不可知论将完全脱离政策。
这样做的原因是它允许 HotSpot 对字节码及其模式有更一致的看法。 如果编译器开始处理边缘情况,则会降低 VM 优化在编译时可能不明显的一般情况的能力。 Steve Yeggie 喜欢强调这一点:在运行时由智能虚拟机执行的优化通常更容易。 他甚至声称 HotSpot 去掉了 javac 的优化。 虽然我不知道这是不是真的,但我不会感到惊讶。
总而言之:针对 VM 的编译器有一套非常不同的标准,特别是在优化领域和适当的时候。 不要责怪编译器编写者将工作留给功能更强大的 JVM。 正如在此线程上多次指出的那样,针对本机架构(如gcc
系列)的现代编译器非常聪明,通过一些非常智能的优化生成了非常快的代码。
我从一开始就没有看到消除死代码的意义。 程序员为什么要写?? 如果您打算对死代码做些什么,请将其声明为编译器错误! 这几乎肯定意味着程序员犯了一个错误——对于少数没有错误的情况,使用变量的编译器指令将是正确的答案。 如果我将死代码放在一个例程中,我希望它被执行——我可能打算在调试器中检查结果。
编译器可以做一些好事的情况是拉出循环不变量。 有时,清晰度会说在循环中编码计算并让编译器将这些东西取出来会很好。
我讨厌在这样一个老问题上提出这个问题(无论如何,我是怎么到这里来的?),但我认为其中一部分可能是 Commodore 64 时代的一个坚持。
在 1980 年代初期,一切都在固定的时钟上运行。 没有 Turbo Boosting,代码总是为具有特定处理器和特定内存等的特定系统创建。在 Commodore BASIC 中,实现delay
s 的标准方法看起来很像:
10 FOR X = 1 TO 1000
20 NEXT : REM 1-SECOND DELAY
(实际上,在实践中,它更类似于10FORX=1TO1000:NEXT
,但你知道我的意思。)
如果他们要优化这一点,它会破坏一切——没有什么是计时的。 我不知道任何例子,但我确信在编译语言的历史中散布着很多这样的小事情,这些事情阻止了事情的优化。
诚然,这些非优化在今天是不必要的。 然而,编译器开发人员可能有一些不言自明的规则,不要优化这样的事情。 我不会知道。
很高兴您的代码在某种程度上进行了优化,这与C64 上的代码不同。 使用最高效的 BASIC 循环,在 C64 上显示位图最多可能需要 60 秒; 因此,大多数游戏等都是用机器语言编写的。 用机器语言编写游戏并不好玩。
只是我的想法。
前提:我在大学学习编译器。
javac 编译器非常愚蠢并且完全不执行优化,因为它依赖于 java 运行时来执行它们。 运行时会捕获那个东西并优化它,但只有在函数执行几千次后才会捕获它。
如果您使用更好的编译器(如 gcc)来启用优化,它将优化您的代码,因为这是一个非常明显的优化。
这是过程代码与功能代码的示例。
您已经详细说明了编译器要遵循的过程,因此优化将基于详细的过程,并将最大限度地减少任何副作用,或者不优化不会按照您的预期执行的操作。 这使得调试更容易。
如果您输入了您想要的功能描述,例如。 SQL 那么你就为编译器提供了广泛的优化选项。
也许某种类型的代码分析能够在运行时找到这种类型的问题或分析,但随后您将希望将源更改为更合理的内容。
因为编译器作者尝试为重要的事情(我希望)添加优化,并且在 *Stone 基准测试中(我担心)。
还有无数其他可能的代码片段,例如您的代码片段,它们什么也不做,并且可以随着编译器编写者的不断努力进行优化,但几乎从未遇到过。
让我感到尴尬的是,即使在今天,大多数编译器都会生成代码来检查 switchValue 是否大于 255,以便在无符号字符上进行密集或几乎完整的切换。 这为大多数字节码解释器的内部循环添加了 2 条指令。
编译器的工作是优化代码如何做某事,而不是代码做什么。
当您编写程序时,您是在告诉计算机该做什么。 如果编译器更改了您的代码以执行您告诉它以外的其他操作,那么它就不会是一个很好的编译器! 当你写x += x + x + x + x + x
,你明确地告诉计算机你希望它把x
设置为自身的 6 倍。 编译器可以很好地优化它如何做这个(如乘法x
6,而不是做重复添加),但无论它仍然会计算在某种程度上该值。
如果你不想做某事,就不要告诉别人去做。
你的两个例子的意思是毫无意义的,没用的,只是为了愚弄编译器。
编译器无法(也不应该)看到方法、循环或程序的含义。 这就是你进入画面的地方。 你为某种功能/意义创建了一个方法,不管它有多愚蠢。 对于简单的问题或极其复杂的程序,情况也是如此。
在您的情况下,编译器可能会对其进行优化,因为它“认为”应该以另一种方式对其进行优化,但为什么要留在那里?
极端的其他情况。 我们有一个智能编译器来编译 Windows。 大量代码需要编译。 但如果它很聪明,它可以归结为 3 行代码......
"starting windows"
"enjoy freecell/solitaire"
"shutting down windows"
其余的代码已经过时了,因为它从未被使用、触及、访问过。 我们真的想要那个吗?
编译器和我们制作的一样聪明。 我不知道有多少程序员会费心编写编译器来检查诸如您使用的结构之类的结构。 大多数集中于提高性能的更典型的方法。
有可能有一天我们将拥有可以真正学习和成长的软件,包括编译器。 当那一天到来的时候,程序员将失业。
它迫使你(程序员)思考你在写什么。 强迫编译器为你做你的工作对任何人都没有帮助:它使编译器变得更加复杂(和更慢!),它让你更愚蠢,对你的代码不那么关注。
声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.