【譯】The story of a V8 performance cliff in React

前言

本文是根據本身的理解翻譯而來,若有疑惑可查看原文。javascript

本次暫定翻譯三篇文章:java

  1. JavaScript engine fundamentals: Shapes and Inline Caches(Published 14th June 2018)
  2. JavaScript engine fundamentals: optimizing prototypes(Published 16th August 2018)
  3. The story of a V8 performance cliff in React(Published 28 August 2019)

JavaScript types

在 JavaScript 中,值有 8 總類型(當前):NumberStringSymbolBigIntBooleanUndefinedNullObjectnode

01-javascript-types
01-javascript-types

除了一個明顯的例外,這些類型均可以用 typeof 直接查看。react

typeof 42;
// → 'number'
typeof 'foo';
// → 'string'
typeof Symbol('bar');
// → 'symbol'
typeof 42n;
// → 'bigint'
typeof true;
// → 'boolean'
typeof undefined;
// → 'undefined'
typeof null;
// → 'object' 🤔
typeof { x: 42 };
// → 'object'
複製代碼

typeof null 返回的是 'object',而不是 'null',要了解爲何,首先要把全部的 JavaScript 類型分紅兩組:git

  • objects(即,對象類型)
  • primitives(即,非對象類型)

照此來講,null 表示「沒有對象」,而 undefined 表示 「沒有值」。github

02-primitives-objects
02-primitives-objects

按照這個思路,Brendan Eich 在設計 JavaScript 時,受到 Java 的影響,使得右手邊的值執行 typeof 後都返回 object。所以,即使規範裏有 Null 類型,typeof null === 'object' 依然成立。編程

03-primitives-objects-typeof
03-primitives-objects-typeof

Value representation

JavaScript 引擎可以在內存中表示任意的 JavaScript 值。然而,值得注意的是,JavaScript 引擎在內存中值類型的表現形式是不一樣於 JavaScript 中的類型描述。數組

例如,42,在 JavaScript 中是 number 類型。ide

typeof 42;
// → 'number'
複製代碼

在內存中有好多種方式表示整數,例如 42:svg

representation bits
two’s complement 8-bit 0010 1010
two’s complement 32-bit 0000 0000 0000 0000 0000 0000 0010 1010
packed binary-coded decimal (BCD) `0100 0010
`
32-bit IEEE-754 floating-point `0100 0010 0010 1000 0000 0000 0000 0000
`
64-bit IEEE-754 floating-point `0100 0000 0100 0101 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000
`

ECMAScript 將數字標準化爲 64 位浮點值,也稱爲雙精度浮點或 Float64。可是,這並不意味着 JavaScript 引擎老是以 Float64 的形式存儲數字 —— 這麼作會很低效。引擎會選擇其它的內部表現形式,除非觀測到的行爲徹底匹配 Float64。

在真實的 JavaScript 應用中,大多數數字都是合法的 ECMAScript 數組索引,即,屬於 0 ~ 2³²−2 範圍內的整數。

array[0]; // Smallest possible array index.
array[42];
array[2**32-2]; // Greatest possible array index.
複製代碼

JavaScript 引擎會爲數字選擇最優存儲的表達形式以此優化數組元素的訪問效率。對於處理器的內存訪問操做,數組索引必須是二進制補碼的形式。用 Float64 表示數組是一種很費性能的行爲,由於每次訪問數組元素引擎都須要在 Float64 和二進制補碼之間轉換。

32 位的二進制補碼錶達形式對數組操做是頗有用的。一般來講,處理器執行整型操做比執行浮點型操做要快得多。因此說,下面的例子,第一個循環比第二個循環快兩倍。

for (let i = 0; i < 1000; ++i) {
  // fast 🚀
}

for (let i = 0.1; i < 1000.1; ++i) {
  // slow 🐌
}
複製代碼

操做符也是同樣。在下面的代碼中,模運算符的性能取決於處理的是不是整數。

const remainder = value % divisor;
// Fast 🚀 if `value` and `divisor` are represented as integers,
// slow 🐌 otherwise.
複製代碼

若是兩個操做數都是整數的形式,CPU 就能夠高效地計算出結果。若是除數是 2 的倍數,V8 還會有額外的捷徑。對於值是浮點型的形式,計算過程會變得複雜耗時。

由於整型操做的執行速度一般比浮點型要快不少,因此,引擎就應該使用二進制補碼來表示全部的整型和整型操做的結果。遺憾的是,那是有悖於 ECMAScript 規範的!ECMAScript 採用了 Float64,某些整數運算實際上產生的是浮點型。在下面這種狀況下,對於可以產生正確的結果很重要。

// Float64 has a safe integer range of 53 bits. Beyond that range,
// you must lose precision.
2**53 === 2**53+1;
// → true

// Float64 supports negative zeros, so -1 * 0 must be -0, but
// there’s no way to represent negative zero in two’s complement.
-1*0 === -0;
// → true

// Float64 has infinities which can be produced through division
// by zero.
1/0 === Infinity;
// → true
-1/0 === -Infinity;
// → true

// Float64 also has NaNs.
0/0 === NaN;
複製代碼

左邊的值都是整型,而右邊的倒是浮點型。以上的操做在使用 32 位二進制補碼的形式是無法正確執行的。JavaScript 引擎必須確保整型操做被合理地處理以生成想要的 Float64 結果。

對於在 31 位有符號整數範圍內的小整數,V8 有特殊的表示形式,稱爲 Smi。對於非 Smi 的數值會被表示爲 HeapObject,它是內存中某些實體的地址。咱們使用一種特殊的 HeapObject,即所謂的 HeapNumber,來表示超出 Smi 範圍的數字。

-Infinity // HeapNumber
-(2**30)-1 // HeapNumber
  -(2**30) // Smi
       -42 // Smi
        -0 // HeapNumber
         0 // Smi
       4.2 // HeapNumber
        42 // Smi
   2**30-1 // Smi
     2**30 // HeapNumber
  Infinity // HeapNumber
       NaN // HeapNumber
複製代碼

如上所示,某些數字被表示爲 Smi,其它數字被表示爲 HeapNumber。V8 對 Smi 專門優化,由於在真實的 JavaScript 編程中,小的整數是很是廣泛的。Smi 不必在內存中分配專用的實體,並且它本能夠快速地整型操做。

Smi vs. HeapNumber vs. MutableHeapNumber

有如下對象:

const o = {
  x: 42,  // Smi
  y: 4.2, // HeapNumber
};
複製代碼

x 的值 42 被編程爲 Smi,所以它被存儲在對象裏。另外一方面值 4.2 須要一個獨立的實例(空間)來保存這個值,而且這個對象會指向這個實體。

04-smi-vs-heapnumber
04-smi-vs-heapnumber

運行如下 JavaScript 代碼片斷:

o.x += 10;
// → o.x is now 52
o.y += 1;
// → o.y is now 5.2
複製代碼

這種狀況下,x 的值能夠就地更新,由於新的值 52 也在 Smi 的範圍內。

05-update-smi
05-update-smi

然而,新的值 y=5.2 不在 Smi 範圍內且不一樣於以前的值 4.2,所以 V8 爲 y 從新分配了新的 HeapNumber 實體。

06-update-heapnumber
06-update-heapnumber

HeapNumber 是不可變的,它使得某些優化成爲可能。例如,咱們把 y 賦值給 x:

o.x = o.y;
// → o.x is now 5.2
複製代碼

咱們只須要把它鏈接到同一個 HeapNumber 上而不是從新分配一個新的實體(空間)。

07-heapnumbers
07-heapnumbers

HeapNumber 不可變也存在缺點,若是更新的值常常不在 Smi 的範圍內,它就會變慢,例以下面的例子:

// Create a `HeapNumber` instance.
const o = { x: 0.1 };

for (let i = 0; i < 5; ++i) {
  // Create an additional `HeapNumber` instance.
  o.x += 1;
}
複製代碼

第一行建立了一個 HeapNumber 實例,其初始值爲 0.1。在循環體中值從 1.1,2.1,3.1,4.2 變到 5.1,一共建立了 6 個 HeapNumber 實例,其中 5 個會在循環結束後變沒有任何用處。

08-garbage-heapnumbers
08-garbage-heapnumbers

爲了不這種狀況,做爲優化,V8 提供了就地更新非 Smi 數值的方法。當一個字段對應着非 Smi 的數值,V8 會在 shape 上將這個字段標記爲 Double,並分配一個保存 Float64 的 MutableHeapNumber 實體。

09-mutableheapnumber
09-mutableheapnumber

當字段裏的值發生變化時,V8 沒必要分配一個新的 HeapNumber,而是在 MutableHeapNumber 實體中就地更新。

10-update-mutableheapnumber
10-update-mutableheapnumber

然而,須要注意的是,MutableHeapNumber 中的值是能夠改變的,因此值不該該傳來傳去的。

11-mutableheapnumber-to-heapnumber
11-mutableheapnumber-to-heapnumber

例如,你把 o.x 賦值給變量 y,你不但願 y 會隨着 o.x 的改變而改變!因此在給 y 賦值前,必須將 o.x 的值從新包裝成 HeapNumber。

對於浮點型,V8 已經默默地包裝了一下。可是,對於小的整數也採用和 MutableHeapNumber 相同的方法,就會顯得浪費,由於 Smi 本就是一種更高效的表現形式。

const object = { x: 1 };
// → no 「boxing」 for `x` in object

object.x += 1;
// → update the value of `x` inside object
複製代碼

爲了不低效率,咱們會在 shape 上爲小整數對應的字段上標記 Smi,而且會原地更新數值,只要這個數值在 Smi 範圍內。

12-smi-no-boxing
12-smi-no-boxing

Shape deprecations and migrations

若是一個字段裏包含的值在 Smi 範圍內,以後又不屬於 Smi 範圍,這中間發生了什麼?現有兩個對象,它們的 x 屬性值都是 Smi 表示形式。

const a = { x: 1 };
const b = { x: 2 };
// → objects have `x` as `Smi` field now

b.x = 0.2;
// → `b.x` is now represented as a `Double`

y = a.x;
複製代碼

這兩個對象都指向同一個 shape,x 屬性特性 Representation 被標記爲 Smi:

13-shape
13-shape

b.x 變成 Double 形式,V8 會建立一個新的 shape,屬性 x 的 Representation 被標記爲 Double 且指向以前的空 shape。V8 也會爲屬性 x 分配一個 MutableHeapNumber 實體用來保存值 0.2。而後讓對象 b 指向新建立的 shape 而且內部偏移量爲 0 的位置指向剛分配的 MutableHeapNumber 實體。最後,咱們把舊的 shape 標記爲廢棄的,並斷開與過渡樹(transition tree)的連接。這就完成了從空 shape 到新 shape 的過渡。

14-shape-transition
14-shape-transition

咱們不能同時徹底刪除舊 shape,由於對象 a 還在使用,並且短期找到全部連接到舊 shape 的對象並更新它們,對 V8 來講是筆很大的開銷。相反,V8 不急着處理:只有在改變對象 a 的時候纔開始遷移到新的 shape。最終,標記爲廢棄的 shape 會慢慢淡出視野並被垃圾回收機制抹除。

15-shape-deprecation
15-shape-deprecation

更棘手的問題是,若是對象上屬性特性 Representation 發生變化的屬性不是 shape 鏈上的最後一個,又會發生什麼呢?

16-split-shape
16-split-shape

從產生分支的 shape 開始,咱們爲屬性 y 建立了一個新的過渡鏈且 y 被標記爲 Double。咱們在使用新的過渡鏈時,也就意味着舊的過渡鏈將被廢棄。在最後一步,咱們把實例 o 遷移到新的 shape 並用 MutableHeapNumber 保存 y 的值。就這樣,新對象再也不使用老的那一套,一旦舊的 shape 上的連接都被移除掉,舊 shape 也會從過渡樹上消失。

Extensibility and integrity-level transitions

Object.preventExtensions() 防止將新屬性添加到對象中。若是你這麼作了,它將會拋異常。(若是是在非嚴格模式下,它不會拋異常而是默認什麼都不作。)

const object = { x: 1 };
Object.preventExtensions(object);
object.y = 2;
// TypeError: Cannot add property y;
// object is not extensible
複製代碼

Object.sealObject.preventExtensions 類似,可是它會把全部的屬性標記爲不可配置,這就意味着你不能刪除它們,或改變他們的可枚舉性,可配置性,可寫性。

const object = { x: 1 };
Object.seal(object);
object.y = 2;
// TypeError: Cannot add property y;
// object is not extensible
delete object.x;
// TypeError: Cannot delete property x
複製代碼

Object.freezeObject.seal 類似,可是它將全部屬性標記爲不可寫以防止屬性值被修改。

const object = { x: 1 };
Object.freeze(object);
object.y = 2;
// TypeError: Cannot add property y;
// object is not extensible
delete object.x;
// TypeError: Cannot delete property x
object.x = 3;
// TypeError: Cannot assign to read-only property x
複製代碼

讓咱們來思考一個具體的例子,有兩個都只有屬性 y 的對象,並阻止第二個對象有任何的擴展。

const a = { x: 1 };
const b = { x: 2 };

Object.preventExtensions(b);
複製代碼

就如咱們所知的,從空的 shape 過渡到一個有屬性 x (被標記爲 Smi)的新 shape 上。當咱們阻止 b 的擴展時,咱們會過渡到標記爲不可擴展的新 shape 上。這個新 shape 沒有任何屬性,僅僅做爲一個標識。

17-shape-nonextensible
17-shape-nonextensible

注意,咱們不能就地更新有 x 的 shape,由於對象 a 依然是可擴展的。

The React performance issue

讓咱們用以上學到的知識來解析下 the recent React issue #14365。簡單重現這個 bug:

const o = { x: 1, y: 2 };
Object.preventExtensions(o);
o.y = 0.2;
複製代碼

有一個擁有兩個字段的對象,並且它們的屬性特性 Representation 被標記爲 Smi。咱們阻止對象的進一步擴展,但最終咱們仍是強制改變第二字段的屬性特性 Representation 的值(Double)。

就如以前學到的,大體流程以下:

18-repro-shape-setup
18-repro-shape-setup

每一個屬性的特性 Representation 都被標記爲 Smi,並最終過渡到被標記爲不可擴展的 shape 上。

咱們須要將 y 的屬性特性 Representation 標記爲 Double,這意味着咱們須要從引入 y 屬性以前的 shape 開始。在這個例子中,就是引入 x 屬性的那個 shape。可是, V8 會很困惑,由於當前的 shape 是不可擴展的,而找到的 shape 倒是可擴展的。V8 不知道怎麼去處理這個過渡樹。所以,V8 再也不試圖搞清楚這些關係,而是建立了一個獨立的 shape,這個 shape 和先前的過渡樹沒有任何關聯,並且也不被任何其它對象共享。能夠把它看成孤立的 shape:

https://user-gold-cdn.xitu.io/2019/10/2/16d8a4a447a973ff?w=960&h=540&f=svg&s=105117
19-orphaned-shape

你能夠想象,若是有不少對象的話,這樣會變得很糟糕,由於整個 shape 系統已經失去價值。

在 React 的案例中,當開始分析數據時,FiberNode 上的一些字段須要記錄時間戳。

class FiberNode {
  constructor() {
    this.actualStartTime = 0;
    Object.preventExtensions(this);
  }
}

const node1 = new FiberNode();
const node2 = new FiberNode();
複製代碼

這些字段(好比,actualStartTime)初始化值是 0 或 -1,所以屬性特性 Representation 爲 Smi。可是,以後由 performance.now() 生成的浮點型數值被保存在這些字段中,所以屬性特性 Representation 被標記爲 Double。除此以外,React 還阻止 FiberNode 實例擴展屬性。

剛開始的情況以下:

20-fibernode-shape
20-fibernode-shape

如預期,兩個實例共享着 shape 樹。可是以後,一旦你存儲了真實的時間戳,V8 就會無從下手:

21-orphan-islands
21-orphan-islands

V8 前後給 node1,node2 分別分配了獨立的 shape,且它們之間沒有任何關聯。真實中的 React 應用有着數萬個這樣的 FiberNode。你能夠想象,這種狀況將會嚴重影響到 V8 的性能。

幸運的是,這個問題V8 v7.4 中解決了。研發人員找到了改變屬性特性 Representation 的方法,V8 終於知道它該怎麼作了:

22-fix
22-fix

兩個 FiberNode 實例指向不可擴展的 shape,shape 中的 actualStartTime 被標記爲 Smi。當 node1.actualStartTime 被分配新的值時,將會生成一條新的過渡鏈,並且以前的過渡鏈會被標記爲廢棄的。

23-fix-fibernode-shape-1
23-fix-fibernode-shape-1

能夠注意到,如今的過渡鏈能夠正確的過渡轉移。

24-fix-fibernode-shape-2
24-fix-fibernode-shape-2

node2.actualStartTime 也被從新分配時,全部的連接都指向了新的 shape,過渡樹中廢棄的部分將會被垃圾回收機制清除。

React 團隊將 FiberNode 全部關於時間的字段都改爲了 Double 形式從而緩解這個問題。

class FiberNode {
  constructor() {
    // Force `Double` representation from the start.
    this.actualStartTime = Number.NaN;
    // Later, you can still initialize to the value you want:
    this.actualStartTime = 0;
    Object.preventExtensions(this);
  }
}

const node1 = new FiberNode();
const node2 = new FiberNode();
複製代碼

由 React 具體的 Bug 引出了 V8 特有的問題,一般來講,開發者沒必要對某個版本的 JavaScript 引擎作優化。不過,當時事情向很差的方向發展時,咱們也不會一籌莫展。

要知道 JavaScript 引擎在幕後作了不少事,儘量的不要去混合類型。例如,給一個數字字段初始化爲 null,這樣作的話會使得一些優化化爲泡影,並且可讀性下降。

// Don’t do this!
class Point {
  x = null;
  y = null;
}

const p = new Point();
p.x = 0.1;
p.y = 402;
複製代碼

換句話說,寫可讀性代碼,性能天然會緊跟其後!

Take-aways

本文覆蓋了一下幾點:

  1. JavaScript 區分了 primitives 和 objects,並且 typeof 不靠譜。
  2. 即便是相同類型的值也會有不一樣的表達形式。
  3. JavaScript 引擎會爲每一個屬性找到最優的表達形式。
  4. 討論了 V8 處理 shape 的廢棄,遷移和可擴展。

基於以上的知識,咱們可使用一些 JavaScript 編程技巧來提高性能:

  1. 以相同的方式初始化對象類型,這樣 shape 系統會更高效。
  2. 爲你的字段選擇合理的值(「Representation」:Smi 或 非 Smi)。
相關文章
相關標籤/搜索