为什么 i ** 2 比 V8 中的 (i + 1) ** 2 慢

Why is i ** 2 slower than (i + 1) ** 2 in V8

考虑以下来自 运行 的片段和结果:

片段 1:

let final_result, final_result2;
let start = new Date();
for(let i = 0; i < 100000000; i++) {
    final_result = Math.pow(i + 1, 2);
}
let end = new Date();
console.log(end - start); // Output 1

let start2 = new Date();
for(let i = 0; i < 100000000; i++) {
    final_result2 = (i + 1) ** 2;
}
let end2 = new Date();
console.log(end2 - start2); // Output 2

片段 2:

let final_result, final_result2;
let start = new Date();
for(let i = 0; i < 100000000; i++) {
    final_result = Math.pow(i, 2);
}
let end = new Date();
console.log(end - start); // Output 1

let start2 = new Date();
for(let i = 0; i < 100000000; i++) {
    final_result2 = i ** 2;
}
let end2 = new Date();
console.log(end2 - start2); // Output 2

片段 3:

let final_result, final_result2;

function t1(){
    for(let i = 0; i < 100000000; i++) {
        final_result = Math.pow(i, 2);
    }
}

function t2(){
    for(let i = 0; i < 100000000; i++) {
        final_result2 = i ** 2;
    }
}

let start = new Date();
t1();
let end = new Date();
console.log(end - start); // Output 1

let start2 = new Date();
t2();
let end2 = new Date();
console.log(end2 - start2); // Output 2

结果:

Output Firefox 88 (ms) Edge 90 (ms)
Snippet 1 - Output 1 63 467
Snippet 1 - Output 2 63 487
Snippet 2 - Output 1 63 468
Snippet 2 - Output 2 63 1180
Snippet 3 - Output 1 64 480
Snippet 3 - Output 2 64 1200

这些结果是在多次测试中一致获得的,并且添加的数量不会影响性能,即其他类似操作((i * 1) ** 2(i + i) ** 2 等)都会导致速度超过只需使用 i ** 2。同时Math.pow速度一致

当使用 V8 浏览器(Edge 和 Chrome 的结果相似)时,(i + n) ** 2 的重复计算如何比 i ** 2 更快,同时Firefox 的运行时在 2 个片段之间是一致的。

How can repeated calculations of (i + n) ** 2 be faster than i ** 2 when the latter has less to calculate?

那是因为这个微基准测试没有测量求幂时间。谨防误导性微基准测试!

相反,它测量的是:

  • HeapNumber 分配和相关操作(写屏障、垃圾收集),
  • 在较慢的情况下,函数调用开销(与内联相反)和 JS 规范规定的一些检查(没有得到优化)。

Spidermonkey 和 V8 之间的一个基本架构差异是前者使用“NaN-boxing”,而后者使用“pointer-tagging”。两者都有;在这种特殊情况下,结果是 V8 需要为您写入 final_result 的每个结果分配一个新的“HeapNumber”,而 Firefox 可以只在那里写入原始 IEEE double。 (这几乎是指针标记方法的最坏情况比较。)这​​解释了两个引擎之间的速度差异。这很容易通过修改测试来验证,这样它将结果存储到一个数组中(即 let final_result = [];final_result[0] = ...)——在这种情况下,V8 的“数组元素类型”跟踪开始并存储原始数据也加倍。

使用 ** 而不是 Math.pow 的较慢情况似乎是 V8 中未开发的优化潜力。来源中有一个关键comment

// We currently don't optimize exponentiation based on feedback.

引入此评论的提交提供了更多背景信息:** 运算符曾经是 Math.pow 的“语法糖”,而 V8 实际上是通过将其“脱糖”给后者来实现的;但是随着 BigInt 支持的引入,它不得不停止这样做。像往常一样,第一个实现旨在正确性而不是最大性能,并且这个第一个实现今天仍在使用(这可能意味着这个细节在现实世界的代码中并不是特别重要......否则之前有人会抱怨) .这意味着 V8 的优化编译器目前缺少内联求幂所需的类型反馈;相反,它发出对“内置”的调用,它必须为它想要的结果分配一个 HeapNumber return。但是,作为一个相当聪明的优化编译器,它可以从其他地方传播类型信息;这就是为什么添加一些其他操作(例如 (i+1) ** 2,甚至 (i+0) ** 2)在这种情况下会产生有益影响的原因。

总结:不要被微基准所愚弄。从微基准测试中得出有用的结论确实需要检查引擎在引擎盖下做什么;否则,您极有可能没有衡量您认为正在衡量的内容。此外,这很好地说明了微基准测试的另一个问题:在您的真实代码中,周围环境可能有所不同(例如,您可能正在存储到数组中,或者您可能正在执行生成类型反馈的额外操作,等),因此微基准测试的结果可能甚至不适用。