React.createElement() 的一个问题引发的思考——为什么对象类型动态变化会导致 js 引擎无法优化

130 阅读11分钟

一、背景

在学习react17更新jsx的动机时,官方文档提到 React.createElement() 设计暴露了一些问题,其中一个问题是:每次执行React.createElement() 时,都要动态的检测一个组件上是否存在.defaultProps 属性,这导致 js 引擎无法对这点进行优化,因为这段逻辑是超多态。

这个问题我当时没有看懂,所以打算查询一些资料来把这句话理解清楚。

在查询资料之前,我把这个问题拆分成了两部分:

  • 动态的检测一个组件上是否存在.defaultProps 属性 = 对象类型动态变化
  • 这导致 js 引擎无法对这点进行优化 ,因为这段逻辑是超多态 = js引擎无法优化超多态的情况

我们从这句话可以得到 1.对象类型动态变化会导致超多态的情况 2.js引擎无法优化超多态的情况。那我们挨个分析一下吧。

二、什么是超多态(megamorphic )

每次执行React.createElement() 时,都要动态的检测一个组件上是否存在.defaultProps 属性。 检测是否存在属性这个动作,在js中也就是访问对象的属性。而在js引擎中是如何实现访问属性的呢? 比如说下面这样的例子:

function f(o){
  return o.x + o.y
}
const x = {x: 3.3,y: 5.5 }
const y = {x: 4.4,y: 6.6 }
f(x)
f(y)

首先让我们先回顾一下js代码运行的过程,分为编译阶段和运行阶段。在编译阶段,js引擎在执行js代码时,会从上到下进行 词法分析、语法分析、语义分析 等处理,并在代码解析完成后生成AST(抽象语法树)。然后在运行阶段,根据AST生成CPU可以执行的机器码并执行。

由于js是动态类型的特点,在编译的时候不能够确定每个变量类型,只有根据运行时的环境去判断具体的类型。所以当我们访问对象的属性时,由于不能提前判断对象类型,js引擎通常采用下图的做法来存储每一个对象。

因为属性没有类型,所以没办法确定它的大小,还需要把属性名都保存下来,因为之后访问属性值都需要通过属性名匹配才能访问到。而且对象b也需要保存相同的属性,因为js的每个对象要自己保存这些信息,这不仅降低了性能还会带来冗余。

为了避免像上面这样的重复性工作,浏览器开始混合加入编译器。其中JIT即时编译器,是一种可以监控代码运行情况和优化重复执行的代码,它可以让 js 运行得更快。

JIT编译器使用的一种优化技术——内联缓存,可以优化对象属性访问过程。具体的优化方式如下: js引擎在执行f({x: 5,y: 6})时,它会把得到的属性路径放入缓存中,并用对象的形状( 这里的形状表示对象里存在的属性和属性的顺序) 作为key,下一次我们看到具有相同形状的对象时,我们就能直接从缓存中拿到对象属性的路径,而不是从头开始再查找一遍。

而且每一个内联缓存都有大小(当前缓存的条目数量)和容量(最大能缓存的条目)。 当用不同形状的对象来调用f,这个操作就会处于多态状态,每增加一种形状的对象,操作的多态等级就会继续上升直到达到一个预定义的阈值,也就是内联缓存的最大容量(比如V8中对于属性访问操作允许的最大容量是4),此后内联缓存将会转化为超多态。

所以说对象类型动态变化时,如果对象的形态超过4,则会进入megamorphic 态。

让我们再来看看问题:每次执行React.createElement() 时,都要动态的检测一个组件上是否存在.defaultProps 属性。 我们知道createElement的type入参: 表示类型,既可以是标签名字符串(如 div 或 span),也可以是 React 组件(如 Foo)。而当我们在访问type.defaultProps时,因为要执行多次createElement,而每次传入createElement的type形态都有可能不同,一旦形态超过4,则会出现megamorphic 态。

那么js引擎是如何处理超多态的呢?

三、js引擎如何处理超多态情况

实际上,js引擎对于超多态的情况完全没有针对性优化,最终只会在IR当中产生一个通用操作。为什么这么说呢?让我们先来看看单态的情况。

3.1 单态

3.1.1 IR -step1

因为我们所写的js代码所包含的类型信息,通常并不足以运行完整的静态类型AOT编译。所以JIT编译器在优化某个函数的时候需要猜测这里会遇到什么样的对象。幸运的是,内联缓存正好会收集这些信息。

  • 单态缓存说:“我见过类型A”
  • 多态缓存说:“我见过类型A1,……,AN”
  • 超态缓存说:“我见过太多的东西”

优化编译器会参考内联缓存收集到的信息,然后生成IR(Intermediate Representation,中间表达形式)。IR指令通常比一般的 js 操作要更底层并且更有针对性。 比如如果 .x 的内联缓存只见过 { x, y } 形状的对象,那么优化编译器就可以使用一种从对象固定位置加载 .x 的IR指令。

3.1.2 类型检查-去优化

当然,对任意的对象都使用这种指令是不保险的,所以优化编译器还会前置一个类型校验。 类型校验会在执行特异操作前先检查对象的形状,如果并不符合预期,优化后的代码就不能继续执行——我们只能跳回到未优化的代码然后在那里继续执行。这个过程被称作去优化。 (导致去优化的原因并不局限于类型校验失败:数学运算可以针对32位整数进行优化,如果结果超出这个范围就会导致去优化;像 arr[idx] 这种根据索引取值可以针对范围内的访问进行优化,如果 idx 超过范围或者 arr 并没有 idx 属性就会导致去优化。)

3.1.3 GVN消除冗余的检查-优化step2

根据类型反馈去构建专用IR指令只是优化流程的第一步。一旦IR准备就绪,编译器会多次运行IR指令,试图发现一些不变量以及消除冗余。这个阶段运行的分析通常是函数内的,编译器每次遇到调用时都不得不考虑最糟糕的情况。比如“+”等式会调用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检查了4次 v0 是否是一样的形状,即使每次检查之间没有任何操作会改变 v0 的形状。加载 v2 和 v5 也是多余的,因为中间也没有任何会改变属性的操作。好在,后续的GVN(Global Value Numbering,全局值计数)流程会消灭掉冗余的检查然后得到:

;; 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 ,因为需要它们来保护我们的访问不会出错。

3.2 非单态操作

如果一个操作不是单态的,优化编译器显然不能用我们之前讨论的简单特型化规则:类型检查 + 特异操作。它没有办法选择一种类型进行校验然后运行一种单一的特异操作。内联缓存告诉编译器这个操作之前遇到过不同的类型或者说形状,所以只选择一种然后忽略其他的,就非常可能会导致去优化。为此,优化编译器同时会构建一个决策树。比如一个 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 之前只见过A、B、C
  // 没有其他的
  $Deoptimize()    // 没能通过类型检查,去优化
}
// 注意:在这里我们只能说 o 可能是A、B、C中的一种
// 但是关于具体是哪种的信息已经丢失了

多态访问不能像单态访问一样能够提供有效信息:在一个特异的单态访问之后,直到一个副作用产生前,我们都可以确定该对象的形状。这就使得在单态操作当中消除冗余变得可能。

而多态只能给一种非常弱的保证:“对象的形状是A、B、C当中的一种”。我们无法利用这个信息来消除两个连续且相似的多态访问之间的冗余(我们最多可以消除后者最后一个条件判断以及去优化的情况,然而V8并不会这么做)。

但是,如果所有形状的对象中,共同的属性都位于相同的位置,V8确实会构建更高效的IR。在这种情况下,会生成一个多态的类型守卫来替代决策树:

// 检查o的形状是AB、C当中的一种,否则去优化
$TypeGuard(o, [A, B, C])
// 加载属性。 它在AB、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 {
  // 我们知道 o.x 多态太严重(超态)。
  // 为了避免去优化,在这里用通用方法处理不确定的对象
  o_x = $LoadPropertyGeneric(o, 'x')
  //    ^^^^^^^^^^^^^^^^^^^^ 不确定的副作用
}
// 注意:到这里,o 的形状信息已经完全丢失
// 并且不确定的副作用可能产生了

在以下情况当中,编译器最终可能会完全放弃生成专有操作

  • 如果不知道如何才能有效地优化它

  • 该操作是多态的,而编译器不知道如何为这种操作构建一个决策树

  • 找不到可分析的相关的类型信息(操作从未执行,或者信息已经被垃圾回收了)

在所有类似的情况下,优化器只会以IR的形式产生一个相应通用操作,而之前缓存的形态信息就没有参考价值了,而且会导致不确定的副作用。所以我们可以得到结论js引擎是没办法优化超多态的情况。

4.总结

原问题:每次执行React.createElement() 时,都要动态的检测一个组件上是否存在.defaultProps 属性,这导致 js 引擎无法对这点进行优化,因为这段逻辑是超多态。

每次执行React.createElement() 时,我们都需要访问type上的defaultProps属性,而因为一个react项目通常要执行多次createElement,每次传入createElement的type形态都有可能不同,一旦形态超过4,则会出现超多态。而我们从上面的流程可知,在js引擎中,如果类型反馈告诉优化编译器,这里的属性访问所见过的形状数量超过了内联处理的限制(超态),优化器最终会产生一个通用操作调用,而不是像单态一样有专有操作,这导致了js引擎无法进行优化。

所以当访问一个不确定类型的对象属性时,或者一个对象内的属性名称和数量会变化时,由于超出内联缓存的最大容量,访问属性的操作将会转化为超多态,导致js引擎无法进行优化。

所以建议大家把ts用起来吧,避免这种情况的发生,优化项目的运行速度!

引用:

1.blog.csdn.net/szengtal/ar… 2.blog.csdn.net/ckwang6/art… 3.blog.51cto.com/u_15127499/… 4.www.qycn.com/xzx/article… 5.www.dengtar.com/21140.html