不可不谈的JavaScript性能核心----单态及多态等(一)

15 篇文章 1 订阅
12 篇文章 1 订阅

本文介绍了我们在V8或者其他JavaScript引擎的介绍中,经常会看到的:单态以及多态等这些概念,结合JavaScript自身的例子,阐释了不同状态下引擎内部的处理过程以及各种状态的特点。本人翻译自Vyacheslav Egorov 的文章,并获得原作者许可。

让我们谈谈JavaScript单态

  那些涉及到JavaScript性能的演讲或是blog中经常会强调单态代码的重要性。但他们通常不会去阐述什么是单态/多态以及它们为什么会这么重要。即使我自己(作者)演讲也经常是类似浩克(绿巨人)式风格,像«ONE TYPE GOOD. TWO TYPE BAD!!!»。所以,不出意外的我收到的关于性能方面最多的请求建议之一就是请求解释究竟什么是单态,多态是如何产生的以及为什么它是糟糕的。

  多态本身的含义–extremely overloaded,对理解它并没有帮助。在一些传统的面向对象语言里面,多态经常指子类(subtyping )以及子类能够重载基类的行为。Haskell程序员可能会认为它和参数化多态性(parametric polymorphism )是一回事。然而,在提及JavaScript性能方面时, 它是指call site (简单理解就是函数被调用的那行代码)多态。
在此之前我已经多次解释过这个概念,并且是通过不同的方式。最终我决定写一篇关于它blog,这样下次就可以直接引用这个blog而不用即兴创作。

动态查找(Dynamic lookup 101)

这里写图片描述
  简化起见,本文内容将以JavaScript中最简单的属性访问为例,就像下面代码的o.x。此外,需要理解我们将讨论的所有内容都适用于任何动态绑定(dynamically bound)操作,比如属性查找或算术符运算,甚至不仅仅局限于JavaScript。

function f(o) {
  return o.x
}
f({ x: 1 })
f({ x: 2 })

  试想一下,你正在面试一家解释器公司的一个不错的职位。你的面试官要求你设计实现一个JavaScript虚拟机的属性查找。这个问题最简单,最直接的答案是什么?
显然,最简单不过的方法是依据ECMAScript语言规范中对JavaScript语义的描述(又名ECMA 262)把 [[Get]] 算法从英文(ECMA 描述内容)逐字逐句翻译成C++, Java, Rust 或者 Malbolge,具体哪个语言就看你的面试要求了。

  事实上,如果你随便打开某个JS解释器,你很可能会看到这样的东西:

jsvalue Get(jsvalue self, jsvalue property_name) {
  // 8.12.3 [[Get]] implementation goes here
}

void Interpret(jsbytecodes bc) {
  // ...
  while (/* has more bytecodes */) {
    switch (op) {
      // ...
      case OP_GETPROP: {
        jsvalue property_name = pop();
        jsvalue receiver = pop();
        push(Get(receiver, property_name));
        // TODO(mraleph): throw exception in strict mode per 8.7.1 step 3.
        break;
      }
      // ...
    }
  }
}

  这绝对是实现属性查找的一种有效方法,但是它有一个明显的问题:如果我们把这个属性查找实现和现代JavaScript虚拟机中使用的相比较,就会发现它太慢了。
  我们所实现的解释器是无状态的:每次它执行属性查找就会执行一遍通用的属性查找算法,它不会根据之前的查找过程积累经验和学习,一次次都是以最大代价(译注:即和第一次通用过程相同)来完成。这也是为什么那些要求性能的JavaScript 虚拟机不会用这种方式实现属性查找。

图片名称
  如果我们程序中的每次属性访问都能够从之前看过到的对象中学习,并将这些学习得到的知识应用于相似(拥有同样结构)的对象,会怎么样?我们大概能想得到,这种方法将会避开那些代价很高的通用查找算法,取而代之的是一种只适用于某种特定结构的快速查找算法,这可以帮我们节省大量的时间。

   我们知道计算给定属性在任意对象中的位置所花费的代价是很大的,所以我们希望只进行一次这样的查找,然后把得到的属性路径(位置)放入缓存中,并用对象的形状(译注:即结构,简单理解为表示对象里存在的属性和属性的顺序,形状在这里比结构更加准确点,所以下面也会用这一词)作为key(译注:实际在V8中,会根据对象的结构生成一个Map,对象的结构信息都会存在这个Map里面)。下一次我们看到具有相同形状的对象时,我们就能直接从缓存中拿到对象属性的路径而不是从头开始再查找一遍。

   这项优化技术叫内联缓存(Inline Caching,缩写IC),我(即作者)以前写过关于它的文章。但在这篇文章中,会把内联缓存具体的实现细节放在一边,相对的,会把重点放在一个容易被忽略点:每一个内联缓存首先是一个缓存,就像其他各种缓存一样有大小(当前缓存的条目数量)和容量(最大能缓存的条目)。
让我们再看下这个例子:

function f(o) {
  return o.x
}

f({ x: 1 })
f({ x: 2 })

   在o.x这里,预期IC会缓存的条目数是多少?
由于 {x: 1} 和 {x: 2} 拥有相同的形状(即隐藏类hidden class或者map),所以答案是1。这恰好是被称为单态(monomorphic)的缓存状态,因为只看到了一种形状的对象。
这里写图片描述

单词monomorphic可以拆解为:[mono- (“one”) + -morphic (“of a form”)]

   如果我们现在用一个不同形状的对象来调用f(译注:即传给对象f不同形状的参数)会发生什么呢?

f({ x: 3 })
// o.x 的缓存现在仍然是单态
f({ x: 3, y: 1 })
// 现在呢?

   {x: 3} 和 {x: 3, y: 1} 是不同形状的对象,所以此时缓存不再是单态。现在它包含两个缓存项,一个用于形状 {x: } ,另一个适用于形状 {x: , y: }。所以现在操作(即属性访问“o.x” ,下同。实际上,这个操作所在一般称为call site)处于多态状态,多态等级为2(译注:即会传入两种对象形状*)。
   接下来,如果我们继续用不同形状的对象来调用f,操作的多态等级将会继续上升直到达到一个预定义的阈值—-内联缓存的最大容量(比如V8中对于属性访问操作允许的最大容量是4),此后内联缓存将会转化为megamorphic 态。

f({ x: 4, y: 1 }) // polymorphic, degree 2
f({ x: 5, z: 1 }) // polymorphic, degree 3
f({ x: 6, a: 1 }) // polymorphic, degree 4
f({ x: 7, b: 1 }) // megamorphic

这里写图片描述
  Megamorphic 状态的存在是为了保护多态内联缓存(译注:缩写PIC)不受控制的增长,这种状态表示“我已经在这个操作这里看到了太多的对象形状,所以我放弃去处理它们了”。在V8中,
megamorphic 态的内联缓存仍然可以继续缓存,但不会再缓存在本地(译注:根据Urs Hölzle的论文来看,这里本地指的是call site所生成的函数桩中,可以简单理解为call site所在的地方),缓
下面有个小练习来检存会被放到全局的一个哈希表里。这个哈希表的的大小是固定的,当出现重复的条目时只是简单的做覆盖操作。
做个小练习,看看你是否已经理解了:

function ff(b, o) {
  if (b) {
    return o.x
  } else {
    return o.x
  }
}

ff(true, { x: 1 })
ff(false, { x: 2, y: 0 })
ff(true, { x: 1 })
ff(false, { x: 2, y: 0 })

函数ff中有几处关于属性访问(而产生)的内联缓存?。
它们处于什么状态?。
答案:这里有两处缓存(因为两处属性访问),并且它们都是单态缓存,因为针对每个call site而言都只看到了一种形状的对象。

性能影响

  到这里,不同状态的内联缓存对性能的影响也应该变得清楚了:

  • 单态是最快的内联缓存状态,因为你能总是能命中缓存 (意味着只需要一次尝试)(ONE TYPE GOOD)
  • 多态状态下的内联缓存会花费线性时间搜索想要的条目(译注:一般情况下会对多台缓存条目进行优化排序,减少几次查找次数
  • megamorphic 状态下的内联缓存要去全局的哈希表里查询,这么看它应该是最慢的内联缓存状态。但是,能够命中全局缓存仍然比彻底的内联缓存丢失要好(即在全局缓存都找不到)。
  • 内联缓存丢失的代价是最大的—你要付出运行期间再附加通用操作的代价。

然而上面我们讲的仅仅是全部内容的一半—内联缓存除了加速你的代码,它还像一个间谍,服务于全能的优化编译器,优化编译器会尝试进一步加快你的代码执行速度。

推测和优化

  由于下面两个问题,仅靠内联缓存达不到最高性能(peak performance)。
- 每一个内联缓存只服务它自己对应的那个call site,对其他的(就像上面的ff函数中两个内联缓存)内联缓存信息一无所知。
- 如果内联缓存不能处理它的输入,那它最终会回退到runtime:这意味着它本质上是一个具有通用副作用以及结果类型往往未知的通用操作符。

function g(o) {
  return o.x * o.x + o.y * o.y
}

g({x: 0, y: 0})

  例如上面的函数,每个内联缓存(总共7个:.x, .x, , .y, .y, , +)都是自行其事,互不关联。每个针对属性访问的内联缓存(译注:这只是一种称呼,意为由于属性访问而产生的内联缓存,下同)都要去检查它的输入o是否和缓存的对象具有相同的形状。在“+”处的算术操作符内联缓存则会检查输入值是否是number(以及什么类型的number,因为V8对数字分成了几类,包括Smi,Number等)–尽管这个信息可以由操作符“*”的内联缓存推导出。
  某些JavaScript中的运算符是有类型的,比如a|0 总会返回32位整数,“+a”返回的肯定是个数字,但是大部分其他操作并没有这种保证。这使得为JavaScript写一个AOT(ahead-of-time)优化编译器是件很困难的事。所以大部分JavaScript虚拟机都不是以AOT方式编译JavaScript(译注:事实上,本身应该是所有都不会有AOT,但现在出现的asm技术使得AOT成为可能,asm.js定义了一个非常有限的JavaScript子集,是完全静态类型的),它们会运行多个执行层(译注:例如解析器,解释器加编译器)。例如在V8中,最初执行的代码没有进行任何优化,并使用基础的非优化编译器进行编译。而在运行一段时间后,那些热门的执行函数会被优化编译器重新编译。(译注:目前V8架构已经便成了解释器加编译器,上面提到的非优化编译器不再存在,而是换成了解释器

  • 减少启动延迟,优化编译器会比普通编译器更慢,占用更多资源。这意味着优化后代码(的作用)应该足够抵消优化过程的代价。
  • 让内联缓存有机会收集类型反馈( type feedback)。

  正如上面强调的那样,人工写出来的代码通常不包含足够的类型信息以允许静态类型分析和AOT编译。所以JIT需要进行推测(译注:即JavaScript的编译方式):对它要优化的代码,根据代码的用法和行为进行有根据的推测,并生成在特定假设下有效的专用代码。换句话说,编译器需要猜测它要优化的函数中特定位置都会看到哪些类型的对象。但幸运的的是,进行推测的信息基础恰好是之前内联缓存收集到的!
这里写图片描述

  • 单态缓存说:“我只看到了类型A”
  • N级多态缓存表示:“我只看到了类型A1, …, AN”
  • Megamorphic 缓存说:“我看到了很多的类型” #-#图片5

  优化编译器根据内联缓存收集到的信息去构建相应的中间表示(intermediate representation,IR)。IR指令通常比一般的JavaScript操作更加具体并且更接近底层。例如,如果属性访问.x对应的内联缓存只看到了一种形状的对象{x,y},那么优化器就可以采用一条IR指令,从对象内的某处固定偏移地址读取属性x(可以参考引用1,我的博客快速属性访问)。当然,对任意对象应用这样的指令是不安全的,所以优化器在执行这条IR指令之前,会设置一个“类型守卫( type guard)”。这个守卫会在特定操作之前检查确认要操作的对象是否是期望的形状。如果不匹配,优化代码将无法继续执行—这个时候必须跳到未优化的代码继续执行(译注:实际在V8中中是到解释器生成的字节码),这个过程叫做去优化。然而,要知道去优化不仅仅是因为类型守卫的检查未通过:比如专门应用于32位整数的算术操作符,如果出现结果溢出也会去优化。索引属性arr[idx]用来数组访问,但是如果idx超过范围或者arr 或不存在索引idx(数组空洞)等,也会进行去优化。
这里写图片描述
  现在我们应该清楚:优化过程就是想要试图解决上面提到的两个缺点。

未优化优化的
每个操作符都有未知的副作用,因为它是通用的并且实现了完整的语义。代码有专门的限制并消除了不可预测性,一些副作用通过明确的操作被消除(比如通过对象内固定偏移地址读取属性的操作没有副作用)
每个操作符都是独立的,单独学习,不与相邻的(译注:比如其他操作符)交换信息操作被分解为较低级别的IR指令,然后再一起进行优化,这可以发现并消除它们之间的冗余(译注:可以参考上面7个内联缓存的那个例子)。

这里写图片描述
  事实上,根据类型反馈去构建专用IR指令只是优化流程的第一步。一旦IR准备就绪,编译器会多次运行IR指令,试图发现一些不变量(译注:可以做常量叠加)以及消除冗余。这个阶段运行的分析通常是函数内的(intraprocedural)(译注:还有另外一种函数之间),编译器每次遇到调用时都不得不考虑最糟糕的情况。这里很重要的一点是认识到某些非专门化通用操作本质是由于操作的副作用产生的,比如“+”等式会调用valueOf方法,属性访问o.x很容易就导致getter调用。所以,由于这些种种情况,编译器无法完全优化每个操作(译注:上面提到的副作用),而这些很可能会成为后续优化的绊脚石。

  一个常见的冗余是关于重复的类型守卫,它们对相同形状对象检查相同的值。下面是函数g的初始IR,大概是这样:

CheckMap v0, {x,y}  ;; shape check 
v1  Load v0, @12        ;; load o.x 
CheckMap v0, {x,y} 
v2  Load v0, @12        ;; load o.x 
i3  Mul v1, v2          ;; o.x * o.x 
CheckMap v0, {x,y} 
v4  Load v0, @16        ;; load o.y 
CheckMap v0, {x,y} 
v5  Load v0, @16        ;; load o.y 
i6  Mul v4, v5          ;; o.y * o.y 
i7  Add i3, i6          ;; o.x * o.x + o.y * o.y

  在上面,IR对v0的形状进行了4次检查,即使在这个过程中不存在可以影响到v0形状的内容。同时,细心的读者会发现序号V2和V5的load指令也是多余的, 因为这个过程没有任何操作会改变load操作里面访问的属性(所以只用一次就足够了)。但幸运的是,随后应用于IR的GVN 译注:可以识别程序中哪两个值是等价的,在语义上可以消除其中一个++ )++技术会消除冗余的检查和load。

;; After GVN 
CheckMap v0, {x,y} 
v1  Load v0, @12 
i3  Mul v1, v1 
v4  Load v0, @16 
i6  Mul v4, v4 
i7  Add i3, i6

  然而,如上所述,这种消除的可行是基于那些冗余操作之间不存在干扰性副作用:如果在V1和V2之间加一行调用代码,我们不得不保守地认为被调用者可能有权访问v0,因此可以添加,删除或者更改v0属性。此时就不能消除V2以及作为类型守卫的CheckMap ,因为需要它们来保护我们的访问不会出错。
  现在我们对优化编译器有了一些基础的了解,知道它喜欢什么(特定化的操作,比如只针对number)和不喜欢什么(通用的调用和访问,需要大段的分支检测),那么只剩下一件事要讨论:优化编译器处理非单态操作。
  如果操作是非单态的,显然优化编译器不能再使用之前讨论过的简单的,针对具体类型的专门化规则的“类型守卫”和“专用操作”(译注:这里专门化是指只针对某一种或者几种类型单独生成操作指令或其他针对性特定操作的过程,下同)。这种情况下无法为类型守卫和专用操作选择出单一类型。内联缓存会告诉编译器,这个操作中看到了超过一种类型/形状 的值,这意味非要选出其中一个(译注:即选出一个传给类型守卫和专门化操作的类型)而忽略其他的将冒着去优化的风险,这是非常不希望看到的。所以,这个时候优化编译器会构建一个决策树。例如,对一个多态属性访问o.x,已经看到了三种形状的对象A,B,C,决策树将是这样的(注意这是伪代码,优化编译器会构建一个CFG结构)(译注:可以参考扩展阅读一

var o_x
if ($GetShape(o) === A) {
  o_x = $LoadByOffset(o, offset_A_x)
} else if ($GetShape(o) === B) {
  o_x = $LoadByOffset(o, offset_B_x)
} else if ($GetShape(o) === C) {
  o_x = $LoadByOffset(o, offset_C_x)
} else {
  // o.x saw only A, B, C so we assume
  // there can be *nothing* else
  $Deoptimize()
}
// 注意: 这里我们只知道o是A,B,C三者中的一种,
// 但运行之前无法确定是哪一个

  这里需要注意的一点是,多态访问缺乏单态访问所具有的有价值的属性信息。针对专门类型的单态访问消除了干扰性的副作用后(译注:例如“+”只用关心是数字运算,而不是字符串连接或者对象的复杂运算),我们可以确保对象的形状是确定唯一的,基于这个事实,我们可以消除单态访问之间的冗余 ,就像上面这个例子。而多态访问只能提供非常弱的保证,它只能说:“对象的形状是A,B或C之一”。我们无法使用这点信息去消除彼此相关的两个相似多态之间的冗余(最多我们可以消除最后的比较模块以及去优化块,即上例中的与C比较的部分和Deoptimize,但V8不会去这么做)
  但是,如果所有形状的对象中(共同的)属性都位于相同的位置,V8确实会构建更高效的IR。在这种情况下,会生成一个多态的类型守卫来替代决策树(译注;区别于上面针对单态的类型守卫

// Check that o's shape is one of A, B or C - deoptimize otherwise.
$TypeGuard(o, [A, B, C])
// Load property. It's in the same place in A, B and C.
var o_x = $LoadByOffset(o, offset_x)

  这种形式的IR对消除冗余有个重要的好处:如果这样两条$TypeGuard(o, [A, B, C])指令之间没有其他干扰性副作用(可能会改变对象形状的操作),第二条冗余指令可以像单态情况一样被消除。(译注:之所以可以采用这种多态类型守卫的形式,是因为几个不同形状的对象具有相同的属性顺序,即访问属性得到的偏移是相同的,不同于上面决策树,不同形状对象的属性偏移不同

  如果类型反馈告诉优化编译器在属性访问处看到了更多的对象形状,超出了优化编译器允许的内联上限,这时候优化编译器将建立一个稍微不同的决策树,尾部会包含通用的属性访问操作,用这种方式来替代去优化(译注:去优化代价很大,去优化意味着生成的优化代码会被废除)。

var o_x
if ($GetShape(o) === A) {
  o_x = $LoadByOffset(o, offset_A_x)
} else if ($GetShape(o) === B) {
  o_x = $LoadByOffset(o, offset_B_x)
} else if ($GetShape(o) === C) {
  o_x = $LoadByOffset(o, offset_C_x)
} else {
  // We know that o.x is too polymorphic (megamorphic).
  // to avoid deoptimizations leave escape hatch to handle
  // arbitrary object:
  o_x = $LoadPropertyGeneric(o, 'x')
  //    ^^^^^^^^^^^^^^^^^^^^ arbitrary side effects
}
// Note: at this point nothing is known about
// o's shape and furthermore arbitrary
// side-effects could have happened.

  最后在某些情况下,优化编译器可以完全放弃专门化的操作(译注:即上面提到针对单态或者多态的操作):

  • 如果它不知道如何有效地专门去优化;
  • 操作是多态的,但优化器不知道如何正确的为此操作构建决策树。(e.g. used to be the case for polymorphic keyed accesses arr[i] in V8 - but not anymore))
  • 操作没有任何可用的类型反馈用于专门化的操作(如操作未执行,GC清除了可用的类型反馈等)

在所有这些(很少见)的情况下,优化器只会生成一个通用操作变体的IR。

性能影响

让我们总结下我们学到的:
  单态操作最容易被专门化(译注:只用针对一种类型),能够提供给优化编译器可行的信息来进一步优化。浩克式的总结:“ONE TYPE CLOSE TO METAL!”

  • 列表内容 以性能速度来说,单态加单态的类型守卫是最快的,接下来是多态加多态类型守卫的形式,最糟糕的是决策树形式的多态。
    • 决策树会使控制流复杂化,并使优化编译器难以消除冗余。如果多态操作恰好位于某个for循环中,则这种反复的进入决策树进行分支判断的去消耗内存行为有点糟糕。
    • 多态的类型守卫仍然能够允许你进行一些冗余消除,但多态的类型守卫仍然比单态类型守卫要慢,这点性能差异主要取决于CPU如何处理条件分支。
  • 超多态/megamorphic 态的操作不能进行专门化,会导致在优化的IR指令中添加一般的通用操作,这种通用操作对于优化和原始的CPU性能都有不好的影响。

可以看下这个测试 microbenchmark,能帮你区分下几种不同属性访问方式:单态,多态并通过相同属性偏移(要求多态类型守卫),多态通过不同的属性偏移(要求决策树)以及megamorphic。

扩展阅读:

一,V8中的多态内联缓存PIC — 源于Smalltalk
二,Explaining JavaScript VMs in JavaScript - Inline Caches
三, Optimizing Dynamically-Typed Object-Oriented Languages WithPolymorphic Inline Caches

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值