为什么 V8 和 spidermonkey 似乎都不展开静态循环?

Why do neither V8 nor spidermonkey seem to unroll static loops?

做一个小检查,看起来既不是 V8 也不是 spidermonkey 展开循环,即使它是完全明显的,它们有多长(字面量作为条件,在本地声明):

const f = () => {
  let counter = 0;
  for (let i = 0; i < 100_000_000; i++) {
    counter++;
  }
  return counter;
};

const g = () => {
  let counter = 0;
  for (let i = 0; i < 10_000_000; i += 10) {
    counter++;
    counter++;
    counter++;
    counter++;
    counter++;
    counter++;
    counter++;
    counter++;
    counter++;
    counter++;
  }
  return counter;
}

let start = performance.now();
f();
let mid = performance.now();
g();
let end = performance.now();

console.log(
  `f took ${(mid - start).toFixed(2)}ms, g took ${(end - mid).toFixed(2)}ms, ` +
  `g was ${((mid - start)/(end - mid)).toFixed(2)} times faster.`
);

有什么原因吗?他们执行相当复杂的优化。标准的 for 循环在 javascript 中不常见吗?


编辑:请注意:有人可能会争辩说,优化可能被延迟了。情况似乎并非如此,尽管我不是这里的专家。我使用 node --allow-natives-syntax --trace-deopt,手动执行优化,并观察到没有发生反优化(用于折叠的片段,实际上无法在浏览器中运行):

const { performance } = require('perf_hooks');

const f = () => {
  let counter = 0;
  for (let i = 0; i < 100_000_000; i++) {
    counter++;
  }
  return counter;
};
// collect metadata and optimize
f(); f();
%OptimizeFunctionOnNextCall(f);
f();

const start = performance.now();
f();
console.log(performance.now() - start);

普通版和展开版都搞定了,效果一样。

我建议您阅读 ,因为它解释得很清楚。简而言之,展开并不意味着代码会 运行 更快。

例如,如果您有一个函数调用(取自链接的答案),而不是简单的 counter++

function one() {
  // something complex running here, in this case a very long comment:
  // bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla
  
  return 1;
}

for (let i = 0; i < 1000; ++i) {
  counter += one();
}

如果函数很短,on-stack replacement以及内联函数会使展开代码更快,但是如果函数很长,循环实际上是更快(又是所有示例,取自我链接的答案)。

  counter += one();
  counter += one();
  ...

现在,从我在大学时使用汇编语言(已由处理器优化)创建一个简单的编译器,一直到 C/C++(由他们自己已经可以生成令人难以置信的高效 ASM 代码),并逐步走向更高级的语言,例如 PHP 和 Javascript:

我的看法是,负责优化的人员必须进行大量启发式分析,并且很可能对能够产生真实结果的真实代码感兴趣。

现在,我无法确定在 for 循环中进行算术运算是否比调用函数更常见,但我的直觉告诉我,使用简单算术运算的 for 循环不太可能在浏览器如今已成为的庞大生态系统中,它非常重要。话又说回来,这是一个很好的学习和深入学习的练习。

(此处为 V8 开发人员。)

TL;DR:因为对于现实世界的代码来说很少值得这样做。

与其他增加代码大小的优化(例如内联)一样,循环展开是一把双刃剑。是的,它可以提供帮助;特别是它通常有助于小玩具示例,例如此处发布的示例。但它也会损害性能,最明显的是因为它增加了编译器必须做的工作量(因此增加了完成这项工作所需的时间),而且还通过副作用,比如更大的代码从 [=28 中获益更少=]的缓存工作。

V8 的优化编译器实际上确实喜欢展开循环的第一次迭代。此外,碰巧的是,我们目前正在进行一个项目来展开更多循环;目前的状态是它有时会有所帮助,有时会造成伤害,所以我们仍在微调启发式方法,以确定何时应该启动,何时不应该启动。这一困难还表明,对于现实世界 JavaScript,收益通常会非常小。

是否为“标准 for 循环”无关紧要;理论上任何循环都可以展开。恰好是这种情况,除了微基准测试之外,循环展开往往没有什么区别:仅仅进行另一次迭代并没有那么多的开销,所以如果循环体做的超过 counter++,就没有了避免每次迭代开销可以获得很多好处。而且,首先,这个每次迭代的开销是 而不是 你的测试测量的是什么:重复的增量都被折叠了,所以你在这里真正比较的是 [=12= 的 100M 次迭代] 针对 counter += 10.

的 10M 次迭代

所以这是许多误导性微基准测试试图欺骗我们得出错误结论的例子之一;-)