为什么在V8中使用此代码段,<=比<慢?

时间:2018-12-06 02:59:18

标签: javascript v8

我正在阅读幻灯片Breaking the Javascript Speed Limit with V8,下面是一个类似以下代码的示例。在这种情况下,我不知道为什么<=<慢,有人可以解释吗?任何评论表示赞赏。

慢:

this.isPrimeDivisible = function(candidate) {
    for (var i = 1; i <= this.prime_count; ++i) {
        if (candidate % this.primes[i] == 0) return true;
    }
    return false;
} 

(提示:质数是长度为prime_count的数组)

更快:

this.isPrimeDivisible = function(candidate) {
    for (var i = 1; i < this.prime_count; ++i) {
        if (candidate % this.primes[i] == 0) return true;
    }
    return false;
} 

[更多信息] ,速度改善非常明显,在我的本地环境测试中,结果如下:

V8 version 7.3.0 (candidate) 

慢:

 time d8 prime.js
 287107
 12.71 user 
 0.05 system 
 0:12.84 elapsed 

更快:

time d8 prime.js
287107
1.82 user 
0.01 system 
0:01.84 elapsed

4 个答案:

答案 0 :(得分:226)

其他答案和评论提到两个循环之间的区别是第一个循环比第二个循环执行更多的迭代。的确如此,但是在一个增长到25,000个元素的数组中,一次或多或少的迭代只会产生微不足道的差异。粗略估计,如果我们假设平均长度增长为12,500,那么我们可能期望的差异应该在1 / 12,500左右,或者只有0.008%。

此处的性能差异远比该额外迭代所解释的要大得多,并且该问题将在演示文稿即将结束时进行解释。

this.primes是一个连续数组(每个元素都包含一个值),并且所有元素都是数字。

JavaScript引擎可以将此类数组优化为实际数字的简单数组,而不是恰好包含数字但可能包含其他值或不包含值的对象的数组。第一种格式的访问速度更快:它需要较少的代码,并且数组更小,因此更适合缓存。但是在某些情况下可能会阻止使用这种优化格式。

一种情况是缺少某些数组元素。例如:

let array = [];
a[0] = 10;
a[2] = 20;

现在a[1]的值是多少?它没有任何价值。 (甚至说它具有值undefined也不正确-包含undefined值的数组元素不同于完全丢失的数组元素。)

没有一种方法只能用数字来表示,因此JavaScript引擎被迫使用优化程度较低的格式。如果a[1]像其他两个元素一样包含数字值,则该数组有可能仅被优化为数字数组。

将数组强制为非优化格式的另一个原因可能是,如果您尝试访问数组范围之外的元素,如演示文稿中所述。

带有<=的第一个循环尝试读取数组末尾的元素。该算法仍然可以正常工作,因为在上一次额外的迭代中:

  • this.primes[i]的值为undefined,因为i在数组末尾。
  • candidate % undefined(对于candidate的任何值)求值为NaN
  • NaN == 0的值为false
  • 因此,return true未执行。

所以好像没有发生额外的迭代-它对其余逻辑没有影响。该代码产生的结果与没有额外迭代的结果相同。

但是要到达那里,它试图读取数组末尾不存在的元素。这迫使数组脱离优化-至少在本次演讲时如此。

第二个带有<的循环仅读取数组中存在的元素,因此它允许优化的数组和代码。

问题的pages 90-91中描述了该问题,在此之前和之后的页面中都进行了相关讨论。

我碰巧参加了这个Google I / O演讲,之后与演讲者(V8的一位作者)进行了交谈。我一直在自己的代码中使用一种技术,该技术涉及读取数组末尾的内容,这是一种误导(事后看来)的尝试,它试图优化一种特定情况。他证实,如果您尝试甚至 read 在数组末尾读取,都会阻止使用简单的优化格式。

如果V8作者所说的仍然正确,那么读取数组末尾的内容将阻止其优化,而必须退回到较慢的格式。

现在有可能同时改进了V8以有效处理这种情况,或者其他JavaScript引擎可以不同地处理它。我对此一无所知,但是这种非优化正是演示文稿所讨论的。

答案 1 :(得分:131)

我在Google上研究V8,并希望在现有答案和评论的基础上提供一些其他见解。

作为参考,这是the slides中的完整代码示例:

var iterations = 25000;

function Primes() {
  this.prime_count = 0;
  this.primes = new Array(iterations);
  this.getPrimeCount = function() { return this.prime_count; }
  this.getPrime = function(i) { return this.primes[i]; }
  this.addPrime = function(i) {
    this.primes[this.prime_count++] = i;
  }
  this.isPrimeDivisible = function(candidate) {
    for (var i = 1; i <= this.prime_count; ++i) {
      if ((candidate % this.primes[i]) == 0) return true;
    }
    return false;
  }
};

function main() {
  var p = new Primes();
  var c = 1;
  while (p.getPrimeCount() < iterations) {
    if (!p.isPrimeDivisible(c)) {
      p.addPrime(c);
    }
    c++;
  }
  console.log(p.getPrime(p.getPrimeCount() - 1));
}

main();

首先,性能差异与<<=运算符没有直接关系。因此,请不要仅仅为了避免代码中的<=而跳绳,因为您在Stack Overflow上读到它很慢---不是!


第二,人们指出该数组是“有孔的”。从OP的帖子中的代码片段中还不清楚,但是当您查看用于初始化this.primes的代码时,这很清楚:

this.primes = new Array(iterations);

这将导致在V8中使用a HOLEY elements kind的数组,即使该数组最终完全填充/打包/连续也是如此。通常,有孔阵列上的操作要比压缩数组上的操作慢,但是在这种情况下,差异可以忽略不计:每次都会有1次额外的Smi(小整数)检查(以防止出现孔)我们在this.primes[i]中的循环中击中isPrimeDivisible。没关系!

TL; DR 这里的数组HOLEY并不是问题。


其他人指出,该代码超出范围。通常建议使用avoid reading beyond the length of arrays,在这种情况下,它确实可以避免性能大幅下降。但是为什么呢? V8 can handle some of these out-of-bound scenarios with only a minor performance impact.那么,这种特殊情况有什么特殊之处?

越界读取导致this.primes[i]在这一行上是undefined

if ((candidate % this.primes[i]) == 0) return true;

这使我们真正想到了 :%运算符现在正与非整数操作数一起使用!

  • integer % someOtherInteger的计算非常有效;在这种情况下,JavaScript引擎可以生成高度优化的机器代码。

  • 另一方面,
  • integer % undefined的效率较低Float64Mod,因为undefined表示为双精度。

通过在此行将<=更改为<,确实可以改善代码片段:

for (var i = 1; i <= this.prime_count; ++i) {

...不是因为<=在某种程度上比<更好,而是因为这样可以避免在这种特殊情况下读取越界。

答案 2 :(得分:19)

TL; DR 循环较慢是由于访问了数组“越界”,它要么迫使引擎以很少甚至没有优化的方式重新编译函数,要么不编译首先使用任何这些优化功能(如果(JIT-)编译器在第一次编译“版本”之前检测到/怀疑此情况),请继续阅读下面的原因;


有人这样说(非常惊讶没有人做过):
曾经有一段时间,OP的片段在初学者编程书中实际上是一个示例,旨在概述/强调javascript中的“数组”从0开始而不是从1开始索引,因此被用作示例常见的“入门错误”(您不喜欢我如何避免使用“编程错误” ;))吗:越界数组访问

示例1:
使用基于0的索引(始终在ES262中),由5个元素组成的Dense Array(连续(意味着索引之间没有间隔)并且实际上每个索引处的元素)。

var arr_five_char=['a', 'b', 'c', 'd', 'e']; // arr_five_char.length === 5
//  indexes are:    0 ,  1 ,  2 ,  3 ,  4    // there is NO index number 5



因此,我们并不是在谈论<<=之间的性能差异(或“一个额外的迭代”),而是在谈论:
“为什么正确的代码段(b)比错误的代码段(a)运行得更快?”

答案是2倍(尽管从ES262语言实施者的角度来看,两者都是优化形式):

  1. 数据表示:如何在内存中内部表示/存储数组(对象,哈希图,“实际”数字数组等)
  2. 功能性机器代码:如何编译访问/处理(读取/修改)这些“数组”的代码

accepted answer对项目1进行了充分(正确的恕我直言)解释,但是在项目2:编译上只花了2个字(“代码”)。

更准确地说:JIT编译,更重要的是JIT- RE -Compilation!

语言规范基本上只是一组算法的描述(“为实现定义的最终结果而执行的步骤”)。事实证明,这是描述语言的一种非常漂亮的方式。 实现者可以将引擎用来实现指定结果的实际方法留给实施者,这为开发者提供了充分的机会来提出更有效的方法来产生定义的结果。 符合规范的引擎应为任何定义的输入提供符合规范的结果。

现在,随着javascript代码/库/用法的增加,并记住“实际”编译器使用了多少资源(时间/内存/等),很明显,我们不能使用户访问网页的时间这么长(并要求他们拥有那么多可用资源)。

想象一下以下简单功能:

function sum(arr){
  var r=0, i=0;
  for(;i<arr.length;) r+=arr[i++];
  return r;
}

完全清楚,对吧?不需要任何其他说明,对吗?返回类型是Number,对吧?
好吧..不,不,不...这取决于您将什么参数传递给命名函数参数arr ...

sum('abcde');   // String('0abcde')
sum([1,2,3]);   // Number(6)
sum([1,,3]);    // Number(NaN)
sum(['1',,3]);  // String('01undefined3')
sum([1,,'3']);  // String('NaN3')
sum([1,2,{valueOf:function(){return this.val}, val:6}]);  // Number(9)
var val=5; sum([1,2,{valueOf:function(){return val}}]);   // Number(8)

看到问题了吗?然后考虑一下这仅仅是勉强刮除可能的大规模排列... 直到完成,我们甚至都不知道函数返回的类型是什么。

现在想象一下,相同的功能-代码实际上是用在不同类型甚至输入的变体上的,它们完全是字面上的描述(在源代码中),而且是动态在程序中生成的“数组”。 / p>

因此,如果只编译一次函数sum,那么唯一的办法总是返回任何和所有类型的输入的规范定义的结果,显然,只能通过执行所有规范规定的main AND子步骤可以保证符合规范的结果(例如未命名的y2k浏览器)。 没有任何优化(因为没有任何假设),并且仍然存在缓慢的解释性脚本语言。

JIT编译(如Just In Time中的JIT)是当前流行的解决方案。

因此,您开始使用关于函数执行,返回和接受的假设来编译函数。
您想出了尽可能简单的检查方法,以检测该函数是否可能开始返回不符合规范的结果(例如因为接收到意外的输入)。 然后,放弃先前的编译结果,然后重新编译为更详细的内容,决定如何处理已经拥有的部分结果(是否值得信任或可以再次确定以确保有效),将该函数重新绑定到程序中,再试一次。最终回到规范中的逐步脚本解释。

所有这些都需要时间!

所有浏览器都在其引擎上运行,对于每个子版本,您都会看到事情有所改善和消退。在历史上的某个时候,字符串是真正不可变的字符串(因此array.join比字符串连接要快),现在我们使用绳索(或类似的绳索)来缓解问题。两者都返回符合规范的结果,这很重要!

长话短说:仅仅因为javascript语言的语义经常受到我们的支持(就像OP例子中的这个无声错误),并不意味着“愚蠢”的错误会增加我们编译器吐出快速机器代码的机会。假设我们编写了“通常”正确的指令:(编程语言的)“用户”当前必须遵循的口头禅是:帮助编译器,描述我们想要的内容,支持常见的习惯用法(从asm.js中获取提示以进行基本理解)浏览器可以尝试优化的内容以及原因)。

因此,谈论性能既很重要,又是一个矿场(而且由于上述雷区,我真的想最后指出(并引用)一些相关材料:

  

访问不存在的对象属性和超出范围的数组元素将返回undefined值,而不引发异常。这些动态功能使使用JavaScript进行编程变得很方便,但同时也使将JavaScript编译成有效的机器代码变得困难。

     

...

     

有效的JIT优化的重要前提是程序员以系统的方式使用JavaScript的动态功能。例如,JIT编译器利用以下事实:对象属性通常以特定顺序添加到给定类型的对象中,或者很少发生超出范围的数组访问。 JIT编译器利用这些规律性假设在运行时生成高效的机器代码。如果代码块满足假设,则JavaScript引擎将执行高效的生成的机器代码。否则,引擎必须退回到较慢的代码或解释程序。

资料来源:
“ JITProf:精确定位不友好的JIT JavaScript代码”
伯克利出版物,2014年,由梁恭,迈克尔·普拉德尔(Michael Pradel),库希克森(Koushik Sen)编写。 http://software-lab.org/publications/jitprof_tr_aug3_2014.pdf

ASM.JS(也不喜欢超出范围的数组访问):

  

提前编译

     

因为asm.js是JavaScript的严格子集,所以此规范仅定义了验证逻辑-执行语义就是JavaScript的执行语义。但是,经过验证的asm.js可以进行提前(AOT)编译。而且,由AOT编译器生成的代码可以非常高效,具有以下特点:

     
      
  • 未装箱的整数和浮点数表示;
  •   
  • 缺少运行时类型检查;
  •   
  • 没有垃圾收集;和
  •   有效的堆加载和存储(实现策略因平台而异)。   
     

无法验证的代码必须通过传统方式(例如解释和/或即时(JIT)编译)重新执行。

http://asmjs.org/spec/latest/

最后是https://blogs.windows.com/msedgedev/2015/05/07/bringing-asm-js-to-chakra-microsoft-edge/
删除边界检查时是否有一小部分关于引擎内部性能的改进(而仅将边界检查提升到循环之外已经有40%的改善)。



编辑:
请注意,有多个资料来源谈到了JIT重新编译的不同层次,直至解释。

理论示例基于上述信息,关于OP的片段:

  • 调用isPrimeDivisible
  • 使用一般假设(例如无界访问)编译isPrimeDivisible
  • 工作
  • BAM,突然数组访问超出范围(在最后)。
  • 废话,引擎说,让我们使用不同的(较少)假设重新编译isPrimeDivisible,并且该示例引擎不会尝试确定是否可以重用当前的部分结果,因此
  • 使用较慢的功能重新计算所有工作(希望它完成了,否则重复一次,这次只是解释代码)。
  • 返回结果

那时的时间是:
第一次运行(最终失败)+在每次迭代中使用较慢的机器代码再次进行所有工作+重新编译等。在此理论示例中,显然要花费> 2倍的时间来完成



编辑2: (免责声明:基于事实的猜想)
我想得越多,我就越认为这个答案实际上可以解释对错误代码段a(或代码段b的性能奖励,取决于您的看法)的“惩罚”的更主要的原因,这就是为什么我很称呼它(代码段a)为编程错误:

很容易假设this.primes是一个“密集数组”纯数字,它要么是

  • 源代码中的硬编码文字(已知的优秀候选者将成为“真实”数组,因为编译器编译之前就已经知道了所有内容)或
  • 最有可能使用以递增顺序填充预大小(new Array(/*size value*/))的数字函数生成的(另一个长期已知的候选对象,将成为“真实”数组)。

我们还知道primes数组的长度被缓存为prime_count ! (表明它的意图和固定大小)。

我们还知道,大多数引擎最初将Array作为“修改时复制”(需要时)传递,这使得处理它们的速度更快(如果您不更改它们的话)。

因此可以合理地假设数组primes在内部已经很可能是一个优化的数组,在创建后不会更改(对于编译器来说很容易知道,如果在创建后没有代码修改该数组)因此已经(以适用于引擎的方式)以一种优化的方式进行了存储,就像 就像是Typed Array一样。

正如我试图通过我的sum函数示例弄清楚的那样,被传递的参数会影响实际需要发生的事情,以及如何将特定代码编译为机器代码。将String传递给sum函数不应更改字符串,而应更改函数的JIT编译方式!将数组传递给sum应该会编译不同版本的机器代码(可能是这种类型的附加形状,或者称为“形状”)。

由于将类Typed_Array的primes数组即时转换为something_else似乎有些不妥,而编译器知道该函数甚至都不会修改它!

在这些假设下,有2种选择:

  1. 假设没有越界,作为数字压缩器进行编译,最后遇到越界问题,重新编译和重做工作(如以上编辑1中的理论示例所述)
  2. 编译器已经预先检测到(或怀疑?)访问权限超出范围,并且该函数是JIT编译的,就好像传递的参数是一个稀疏对象导致了较慢的功能机器代码(因为它将进行更多的检查/转换/强制等)。换句话说:该函数从来没有资格进行某些优化,而是像接收到“稀疏数组”(类似)参数一样进行编译。

我现在真的很奇怪这是哪两个!

答案 3 :(得分:6)

要添加一些科学性,这是一个jsperf

https://jsperf.com/ints-values-in-out-of-array-bounds

它测试一个数组的控制情况,该数组填充有整数并且在不超出界限的情况下进行模块化算术循环。它有5个测试用例:

  • 1。越界
  • 2。多孔阵列
  • 3。针对NaN的模块化算法
  • 4。完全不确定的值
  • 5。使用new Array()

表明前4个案例对性能的影响确实。越界效果要好于其他三个,但所有四个都比最佳情况慢98%。
new Array()的情况几乎与原始数组一样好,只慢了百分之几。