關於React的一個V8性能瓶頸背後的故事

譯註: 原文做者是Mathias Bynens, 他是V8開發者,這篇文章也發佈在V8的博客上。他的相關文章質量很是高,若是你想了解JavaScript引擎內部是如何工做的,他的文章必定不能錯過。後面我還會翻譯他的其餘文章,一方面是他文章質量很高,另一方面是我想學習一下他們是怎麼寫文章的,經過翻譯文章,讓我能夠更好地消化知識和模仿寫做技巧, 最後奇文共賞!node

原文連接: The story of a V8 performance cliff in Reactreact

以前咱們討論過Javascript引擎是如何經過Shape(外形)和內聯緩存(Inline Caches)來優化對象和數組的訪問的, 咱們還特別探討了Javascript引擎是如何加速原型屬性訪問. 這篇文章講述V8如何爲不一樣的Javascript值選擇最佳的內存表示(representations), 以及它是如何影響外形機制(shape machinery)的。這些能夠幫助咱們解釋最近React內核出現的V8性能瓶頸(Performance cliff)問題git

若是不想看文章,能夠看這個演講: 「JavaScript engine fundamentals: the good, the bad, and the ugly」github


JavaScript 類型

每個Javascript值都屬於如下八個類型之一(目前): Number, String, Symbol, BigInt, Boolean, Undefined, Null, 以及 Object.編程

可是有個總所周知的例外,在JavaScript中能夠經過typeof操做符觀察值的類型:數組

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', 儘管Null有一個本身的類型。要理解爲何,考慮將全部Javascript類型的集合分爲兩組:緩存

  • 對象類型 (i.e. Object類型)
  • 原始(primitives)類型 (i.e. 任何非對象值)

所以, null能夠理解爲"無對象值", 而undefined則表示爲「無值」.安全

譯註:也就是,null能夠理解爲對象類型的'undefined';而undefined是全部類型的'undefined'性能

遵循這個思路,Brendan Eich 在設計Javascript的時候受到了Java的影響,讓typeof右側全部值(即全部對象和null值)都返回'object'. 這就是爲何typeof null === 'object'的緣由, 儘管規範中有一個單獨的Null類型。學習


值的表示

Javascript引擎必須可以在內存中表示任意的Javascript值. 然而,須要注意的是,Javascript的值類型和Javascript引擎如何在內存中表示它們是兩回事.

例如值 42,Javascript中是number類型。

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

在內存中有不少種方式來表示相似42這樣的整型數字:

表示
8-bit二進制補碼 0010 1010
32-bit二進制補碼 0000 0000 0000 0000 0000 0000 0010 1010
壓縮二進制編碼十進制(packed binary-coded decimal (BCD)) 0100 0010
32-bit IEEE-754 浮點數 0100 0010 0010 1000 0000 0000 0000 0000
64-bit IEEE-754 浮點數 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 to 2³²−2之間的整型值.

array[0]; // 最小合法的數組索引.
array[42];
array[2**32-2]; // 最大合法數組索引.
複製代碼

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 🚀 若是 `value` 和 `divisor` 都表示爲整型,
// slow 🐌 其餘狀況
複製代碼

若是兩個操做數都表示爲整型,CPU就能夠很是高效地計算結果。若是divisor是2的冪, V8還有額外的快速通道(fast-paths)。對於表示爲浮點樹的值,計算則要複雜的多,而且須要更長的時間.

由於整型操做一般都比浮點型操做要快得多,因此引擎彷佛能夠老是使用二進制補碼來表示全部整型值和整型的計算結果。不幸的是,這會違反ECMAScript規範!ECMAScript是在Float64基礎上進行標準化,所以實際上某些整數操做也可能會輸出浮點數。在這種狀況下,JS引擎輸出正確的結果更重要。

// Float64 的安全整型範圍是 53 bits. 超過這個返回會失去精度,
2**53 === 2**53+1;
// → true

// Float64 支持-0, 索引 -1 * 0 必須是 -0, 可是二進制補碼是沒辦法表示-0.
-1*0 === -0;
// → true

// Float64 能夠經過除以0來獲得無窮大.
1/0 === Infinity;
// → true
-1/0 === -Infinity;
// → true

// Float64 還有NaN.
0/0 === NaN;
複製代碼

儘管左側都是整型,右側全部值卻都是浮點型。這就是爲何32位二進制補碼不能正確地執行上面這些操做。因此JavaScript引擎必須特別謹慎,以確保整數操做能夠適當地回退,從而輸出花哨(符合規範)的Float64結果。

對於31位有符號整數範圍內的小整數,V8使用一個稱爲Smi(譯註: Small Integer)的特殊表示。其餘非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
複製代碼

如上所示,一些JavaScript數字表示爲Smi,而另外一些則表示爲HeapNumber. V8特地爲Smi優化過,由於小整數在實際JavaScript程序中太常見了。Smi不須要在內存中額外分配專門的實體, 能夠進行快速的整型操做.

這裏更重要的一點是,即便是相同Javascript類型的值,爲了優化,背後可能會以徹底不一樣的方式進行表示



Smi vs. HeapNumber vs. MutableHeapNumber

下面介紹它們底層是怎麼工做的。假設你有下列對象:

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

x的值42能夠被編碼爲Smi,因此你能夠在對象本身內部進行保存。另外一方面,值4.2則須要一個單獨的實體來保存,而後對象再指向這個實體.

如今開始執行下面的Javascript片斷:

o.x += 10;
// → o.x 如今是 52
o.y += 1;
// → o.y 如今是 5.2
複製代碼

這種狀況下,x的值能夠被原地(in-place)更新,由於新的值52仍是符合Smi的範圍.

然而,新值y=5.2不符合Smi,且和以前的值4.2不同,因此V8必須分配一個新的HeapNumber實體,再賦值給y。

HeapNumber是不可變的,這也讓某些優化成爲可能。舉個例子,若是咱們將y的值賦給x:

o.x = o.y;
// → o.x 如今是 5.2
複製代碼

...咱們如今能夠簡單地連接到同一個HeapNumber,而不是分配一個新的.

HeapNumbers不可變的一個缺點是,頻繁更新字段不在Smi範圍內的值會比較慢,以下例所示:

// 建立一個 `HeapNumber` 實例.
const o = { x: 0.1 };

for (let i = 0; i < 5; ++i) {
  // 建立另外一個 `HeapNumber` 實例.
  o.x += 1;
}
複製代碼

第一行經過初始化值0.1建立一個HeapNumber實例。循環體將它的值改變爲1.12.13.14.1、最後是5.1,這個過程總共建立了6個HeapNumber實例,其中5個會在循環結束後被垃圾回收。

爲了不這個問題,V8也提供了一種機制來原地更新非Smi數字字段做爲優化。當一個數字字段保存的值超出了Smi的範圍後,V8會在Shape中將這個字段標記爲Double, 而且分配一個稱爲MutableHeapNumber實體來保存實際的值。

譯註: 關於Shape是什麼,能夠閱讀這篇文章, 簡單說Shape就是一個對象的‘外形’,JavaScript引擎能夠經過Shape來優化對象的屬性訪問。

如今當字段的值變更時,V8不須要在分配一個新的HeapNumber,而是直接原地更新MutableHeapNumber.

然而,這種方式也有一個缺陷。由於MutableHeapNumber的值能夠被修改,因此這些值不能安全傳遞給其餘變量

舉個例子,若是你將o.x賦值給其餘變量y,你可不想下一次o.x變更時影響到y的值 —— 這違反了JavaScript規範!所以,當o.x被訪問後,在將其賦值給y以前,必須將該數字從新裝箱(re-boxed)成一個常規的HeapNumber

對於浮點數,V8會在背後執行全部上面提到的「包裝(boxing)」魔法。可是對於小整數來講,使用MutableHeapNumber就是浪費,由於Smi是更高效的表示。

const object = { x: 1 };
// → 不須要‘包裝’x字段

object.x += 1;
// → 直接在對象內部更新
複製代碼

爲了不低效率,對於小整數,咱們必須在Shape上將該字段標記爲Smi表示,只要符合小整數的範圍,咱們就能夠簡單地原地更新數字值。


Shape 廢棄和遷移

那麼,若是一個字段初始化時是Smi,可是後續保存了一個超出小整數方位的值呢?好比下面這種狀況,兩個對象都使用相同的Shape,即x在初始化時表示爲Smi:

const a = { x: 1 };
const b = { x: 2 };
// → 對象如今將 `x`字段 表示爲 `Smi`

b.x = 0.2;
// → `b.x` 如今表示爲 `Double`

y = a.x;
複製代碼

一開始兩個對象都指向同一個Shapex被標記爲Smi表示:

b.x修改成Double表示時,V8會分配一個新的Shape,將x設置爲Double表示,而且它會指向回空Shape(譯註:Shape是樹結構)。另外V8還會分配一個MutableHeapNumber來保存x的新值0.2. 接着咱們更新對象b指向新的Shape,而且修改對象的x指向剛纔分配的MutableHeapNumber。最後,咱們標記舊的Shape爲廢棄狀態,並從轉換樹(transition tree)中移除。這是經過將「x」從空Shape轉換爲新建立的Shape的方式來完成的。

這個時候咱們還不能徹底將舊Shape刪除掉,由於它還被a使用着,並且你不能着急遍歷內存來找出全部指向舊Shape的對象,這種作法過低效。因此V8採用惰性方式: 對於a的任意屬性的訪問和賦值,會首先遷移到新的Shape。這樣作, 最終能夠將廢棄的Shape變成‘不能到達(unreachable)’, 讓垃圾回收器釋放掉它。

若是修改表示的字段不是鏈中的最後一個字段,則會出現更棘手的狀況:

const o = {
  x: 1,
  y: 2,
  z: 3,
};

o.y = 0.1;
複製代碼

這種狀況,V8須要找到所謂的分割Shape(split shape), 即相關屬性在被引入到Shape鏈以前的Shape。這裏咱們修改的是y,因此咱們能夠找到引入y以前的最後一個Shape,在上面的例子中這個Shape就是x

分割Shape(即x)開始,咱們爲y建立一個新的轉換鏈, 它將y標記爲Double表示,並重放(replay)以前的其餘轉換. 咱們將對y應用這個新的轉換鏈,將舊的樹標記爲廢棄。在最後一步,咱們將實例o遷移到新的Shape,如今使用一個MutableHeapNumber來保存y的值。後面新建立的對象都不會使用舊的Shape的路徑,一旦全部舊Shape的引用都移除了,Shape樹的廢棄部分就會被銷燬。

可擴展性和完整性級別轉換

Object.preventExtensions()阻止新的屬性添加到對象中, 不然它就會拋出異常。(若是你不在嚴格模式,它將不會拋出異常,而是什麼都不幹)

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

Object.sealObject.preventExtensions相似,只不過它將全部屬性標記爲non-configurable, 這意味着你不能刪除它們, 或者改變它們的ConfigurableEnumerableWritable屬性。

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
複製代碼

讓咱們考慮這樣一個具體的例子,下面兩個對象都包含單個x屬性,後者還阻止了對象擴展。

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

Object.preventExtensions(b);
複製代碼

咱們都知道它一開始會從空Shape轉換爲一個包含x(表示爲Smi)的新Shape。當咱們阻止b被擴展時,咱們會執行一項特殊的轉換,建立一個新的Shape並標記爲'不可擴展'。這個特殊的轉換不會引入任何新的屬性 —— 它只是一個標記

注意,咱們不能原地更新xShape,由於它還被a對象引用,a對象仍是可擴展的。


React的性能問題

讓咱們將上述全部東西都放在一塊兒,用咱們學到的知識來理解最近的React Issue #14365. 當React團隊在分析一個真實的應用時,他們發現了V8一個影響React 核心的奇怪性能問題. 下面是這個bug的簡單復現:

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

一開始咱們這個對象有兩個Smi表示的字段。接着咱們還阻止了對象擴展,最後還強制將第二個字段轉換爲Double表示。

按照咱們上面描述的,這大概會建立如下東西:

兩個屬性都會被標記爲Smi表示,最後一個轉換是可擴展性轉換,用於將Shape標記爲不可擴展。

如今咱們須要將y轉換爲Double表示,這意味着咱們又要開始找出分割Shape. 在這個例子中,分割Shape就是引入x的那個Shape。可是V8會有點迷惑,由於分割Shape是可擴展的,而當前Shape卻被標記爲不可擴展。在這種狀況下,V8並不知道如何正確地重放轉換。因此V8乾脆上放棄了嘗試理解它,直接建立了一個單獨的Shape,它沒有鏈接到現有的Shape樹,也沒有與任何其餘對象共享。能夠把它想象成一個孤兒Shape

你能夠想象一下,若是有不少對象都這樣子有多糟糕,這會讓整個Shape系統變得毫無用處。

在React的場景中,每一個FiberNode在打開分析器時都會有好幾個字段用於保存時間戳.

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

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

這些字段(例如actualStartTime)被初始化爲0或-1,即一開始時是Smi表示。後來,這些字段賦值爲performance.now()輸出的時間戳,這些時間戳實際是浮點型的。由於不符合Smi範圍,它們須要轉換爲Double表示。剛好在這裏,React還阻止了FiberNode實例的可擴展性。

上面例子的初始狀態以下:

按照咱們設想的同樣, 這兩個實例共享了同一個Share樹. 後面,當你保存真正的時間戳時,V8找到分割Shape就迷惑了:

V8給node1分配了一個新的孤兒Shapenode2同理,這樣就生成了兩個孤島,每一個孤島都有本身不相交的Shape。大部分真實的React應用有上千上萬個FiberNode。能夠想象到,這種狀況對V8的性能不是特別樂觀。

幸運的是,咱們在V8 v7.4修復了這個性能問題, 咱們也正在研究如何下降修改字段表示的成本,以消滅剩餘的性能瓶頸. 通過修復後,V8能夠正確處理這種狀況:

兩個FiberNode實例都指向了'actualStartTime'爲Smi的不可擴展Shape. 當第一次給node1.actualStartTime賦值時,將建立一個新的轉換鏈,並將以前的鏈標記爲廢棄。

注意, 如今擴展性轉換能夠在新鏈中正確重放。

在賦值node2.actualStartTime以後,兩個節點都引用到了新的Shape,轉換樹中廢棄的部分將被垃圾回收器回收。

在Bug未修復以前,React團隊經過確保FiberNode上的全部時間和時間段字段都初始化爲Double表示,來緩解了這個問題:

class FiberNode {
  constructor() {
    // 在一開始強制爲Double表示.
    this.actualStartTime = Number.NaN;
    // 後面依舊能夠按照以前的方式初始化值
    this.actualStartTime = 0;
    Object.preventExtensions(this);
  }
}

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

除了Number.NaN, 任何浮點數值都不在Smi的範圍內, 能夠用於強制Double表示。例如0.000001, Number.MIN_VALUE, -0Infinity

值得指出的是,這個具體的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;
複製代碼

譯註:若是你使用Typescript,應該開啓strictNull模式

換句話說,編寫高可讀的代碼,是能夠提升性能的!

總結

咱們深刻討論了下列內容:

  • JavaScript 區分了‘原始類型’和‘對象類型’,typeof是一個騙子
  • 即便是相同Javascript類型的值,底層可能有不一樣的表示
  • V8嘗試給你的Javascript程序的每一個屬性找出一個最優的表示
  • 咱們還討論了V8是如何處理Shape廢棄和遷移的,另外還包括擴展性轉換

基於這些知識,咱們總結出了一些能夠幫助提高性能的JavaScript編程技巧:

  • 始終按照一致的方式初始化你的對象,這樣Shape會更有效
  • 爲字段選擇合理的初始值,以幫助JavaScript引擎選擇最佳的表示。


相關文章
相關標籤/搜索