JavaScript 在 V8 中的元素種類及性能優化

原文:「Elements kinds」 in V8javascript

JavaScript 對象能夠具備與它們相關聯的任意屬性。對象屬性的名稱能夠包含任何字符。JavaScript 引擎能夠進行優化的一個有趣的例子是當屬性名是純數字時,一個特例就是數組索引的屬性。html

在 V8 中,若是屬性名是數字(最多見的形式是 Array 構造函數生成的對象)會被特殊處理。儘管在許多狀況下,這些數字索引屬性的行爲與其餘屬性同樣,V8 選擇將它們與非數字屬性分開存儲以進行優化。在引擎內部,V8 甚至給這些屬性一個特殊的名稱:元素。對象具備映射到值的屬性,而數組具備映射到元素的索引。java

儘管這些內部結構從未直接暴露給 JavaScript 開發人員,但它們解釋了爲何某些代碼模式比其餘代碼模式更快。git

常見的元素種類

運行 JavaScript 代碼時,V8 會跟蹤每一個數組所包含的元素。這些信息能夠幫助 V8 優化數組元素的操做。例如,當您在數組上調用 reducemapforEach 時,V8 能夠根據數組包含哪些元素來優化這些操做。github

拿這個數組舉例:shell

const array = [1, 2, 3];

它包含什麼樣的元素?若是你使用 typeof 操做符,它會告訴你數組包含 numbers。在語言層面,這就是你所獲得的:JavaScript 不區分整數,浮點數和雙精度 - 它們只是數字。然而,在引擎級別,咱們能夠作出更精確的區分。這個數組的元素是 PACKED_SMI_ELEMENTS。在 V8
中,術語 Smi 是指用於存儲小整數的特定格式。(後面咱們會在 PACKED 部分中說明。)數組

稍後在這個數組中添加一個浮點數將其轉換爲更通用的元素類型:ide

const array = [1, 2, 3];
// 元素類型: PACKED_SMI_ELEMENTS
array.push(4.56);
// 元素類型: PACKED_DOUBLE_ELEMENTS

向數組添加字符串再次改變其元素類型。函數

const array = [1, 2, 3];
// 元素類型: PACKED_SMI_ELEMENTS
array.push(4.56);
// 元素類型: PACKED_DOUBLE_ELEMENTS
array.push('x');
// 元素類型: PACKED_ELEMENTS

到目前爲止,咱們已經看到三種不一樣的元素,具備如下基本類型:性能

  • 小整數,又稱 Smi。
  • 雙精度浮點數,浮點數和不能表示爲 Smi 的整數。
  • 常規元素,不能表示爲 Smi 或雙精度的值。

請注意,雙精度浮點數是 Smi 的更爲通常的變體,而常規元素是雙精度浮點數之上的另外一個歸納。能夠表示爲 Smi 的數字集合是能夠表示爲
double 的數字的子集。

這裏重要的一點是,元素種類轉換隻能從一個方向進行:從特定的(例如 PACKED_SMI_ELEMENTS)到更通常的(例如 PACKED_ELEMENTS)。例如,一旦數組被標記爲 PACKED_ELEMENTS,它就不能回到 PACKED_DOUBLE_ELEMENTS

到目前爲止,咱們已經學到了如下內容:

V8 爲每一個數組分配一個元素種類。數組的元素種類並無被捆綁在一塊兒 - 它能夠在運行時改變。在前面的例子中,咱們從 PACKED_SMI_ELEMENTS 過渡到 PACKED_ELEMENTS。元素種類轉換隻能從特定種類轉變爲更廣泛的種類。

PACKED vs HOLEY

密集數組 PACKED 和稀疏數組 HOLEY

到目前爲止,咱們只處理密集或打包(PACKED)數組。在數組中建立稀疏數組將元素降級到其 HOLEY 變體:

const array = [1, 2, 3, 4.56, 'x'];
// 元素類型: PACKED_ELEMENTS
array.length; // 5
array[9] = 1; // array[5] until array[8] are now holes
// 元素類型: HOLEY_ELEMENTS

V8 之因此作這個區別是由於 PACKED 數組的操做比在 HOLEY 數組上的操做更利於進行優化。對於 PACKED 數組,大多數操做能夠有效執行。相比之下, HOLEY 數組的操做須要對原型鏈進行額外的檢查和昂貴的查找。

到目前爲止,咱們看到的每一個基本元素(即 Smis,double 和常規元素)有兩種:PACKEDHOLEY。咱們不只能夠從 PACKED_SMI_ELEMENTS 轉變爲 PACKED_DOUBLE_ELEMENTS 咱們也能夠從任何 PACKED 形式轉變成 HOLEY 形式。

回顧一下:

最多見的元素種類 PACKEDHOLEYPACKED 數組的操做比在 HOLEY 數組上的操做更爲有效。元素種類可從過渡 PACKED 轉變爲 HOLEY

The elements kind lattice

元素種類的格

V8 將這個變換系統實現爲格(數學概念)。這是一個簡化的可視化,僅顯示最多見的元素種類:

只能經過格子向下過渡。一旦將單精度浮點數添加到 Smi 數組中,即便稍後用 Smi 覆蓋浮點數,它也會被標記爲 DOUBLE。相似地,一旦在數組中建立了一個洞,它將被永久標記爲有洞 HOLEY,即便稍後填充它也是如此。

V8 目前有 21 種不一樣的元素種類,每種元素都有本身的一組可能的優化。

通常來講,更具體的元素種類能夠進行更細粒度的優化。元素類型的在格子中越是向下,該對象的操做越慢。爲了得到最佳性能,請避免沒必要要的不具體類型 - 堅持使用符合您狀況的最具體的類型。

性能提示

在大多數狀況下,元素種類的跟蹤操做都隱藏在引擎下面,您不須要擔憂。可是,爲了從系統中得到最大的收益,您能夠採起如下幾方面。再次重申:更具體的元素種類能夠進行更細粒度的優化。元素類型的在格子中越是向下,該對象的操做越慢。爲了得到最佳性能,請避免沒必要要的不具體類型 - 堅持使用符合您狀況的最具體的類型。

避免建立洞(hole)

假設咱們正在嘗試建立一個數組,例如:

const array = new Array(3);
// 此時,數組是稀疏的,因此它被標記爲 `HOLEY_SMI_ELEMENTS`
// i.e. 給出當前信息的最具體的可能性。
array[0] = 'a';
// 接着,這是一個字符串,而不是一個小整數...因此過渡到`HOLEY_ELEMENTS`。
array[1] = 'b';
array[2] = 'c';
// 這時,數組中的全部三個位置都被填充,因此數組被打包(即再也不稀疏)。
// 可是,咱們沒法轉換爲更具體的類型,例如 「PACKED_ELEMENTS」。
// 元素類保留爲「HOLEY_ELEMENTS」。

一旦數組被標記爲有洞,它永遠是有洞的 - 即便它被打包了!從那時起,數組上的任何操做均可能變慢。若是您計劃在數組上執行大量操做,而且但願對這些操做進行優化,請避免在數組中建立空洞。V8 能夠更有效地處理密集數組。

建立數組的一種更好的方法是使用字面量:

const array = ['a', 'b', 'c'];
// elements kind: PACKED_ELEMENTS

若是您提早不知道元素的全部值,那麼能夠建立一個空數組,而後再 push 值。

const array = [];
// …
array.push(someValue);
// …
array.push(someOtherValue);

這種方法確保數組不會被轉換爲 holey elements。所以,V8 能夠更有效地優化數組上的任何操做。

避免讀取超出數組的長度

當讀數超過數組的長度時,例如讀取 array[42] 時,會發生相似的狀況 array.length === 5。在這種狀況下,數組索引 42 超出範圍,該屬性不存在於數組自己上,所以 JavaScript 引擎必須執行相同的昂貴的原型鏈查找。

不要這樣寫你的循環:

// Don’t do this!
for (let i = 0, item; (item = items[i]) != null; i++) {
  doSomething(item);
}

該代碼讀取數組中的全部元素,而後再次讀取。直到它找到一個元素爲 undefinednull 時中止。(jQuery 在幾個地方使用這種模式。)

相反,將你的循環寫成老式的方式,只須要一直迭代到最後一個元素。

for (let index = 0; index < items.length; index++) {
  const item = items[index];
  doSomething(item);
}

當你循環的集合是可迭代的(數組和 NodeLists),還有更好的選擇:只須要使用 for-of。

for (const item of items) {
  doSomething(item);
}

對於數組,您可使用內置的 forEach

items.forEach((item) => {
  doSomething(item);
});

現在,二者的性能 for-offorEach 能夠和舊式的 for 循環相提並論。

避免讀數超出數組的長度!這樣作和數組中的洞同樣糟糕。在這種狀況下,V8 的邊界檢查失敗,檢查屬性是否存在失敗,而後咱們須要查找原型鏈。

避免元素種類轉換

通常來講,若是您須要在數組上執行大量操做,請嘗試堅持儘量具體的元素類型,以便 V8 能夠儘量優化這些操做。

這比看起來更難。例如,只需給數組添加一個 -0,一個小整數的數組便可將其轉換爲 PACKED_DOUBLE_ELEMENTS

const array = [3, 2, 1, +0];
// PACKED_SMI_ELEMENTS
array.push(-0);
// PACKED_DOUBLE_ELEMENTS

所以,此數組上的任何操做都將以與 Smi 徹底不一樣的方式進行優化。

避免 -0,除非你須要在代碼中明確區分 -0+0。(你可能並不須要)

一樣還有 NaNInfinity。它們被表示爲雙精度,所以添加一個 NaNInfinity 會將 SMI_ELEMENTS 轉換爲
DOUBLE_ELEMENTS

const array = [3, 2, 1];
// PACKED_SMI_ELEMENTS
array.push(NaN, Infinity);
// PACKED_DOUBLE_ELEMENTS

若是您計劃對整數數組執行大量操做,在初始化的時候請考慮規範化 -0,而且防止 NaN 以及 Infinity。這樣數組就會保持 PACKED_SMI_ELEMENTS

事實上,若是你對數組進行數學運算,能夠考慮使用 TypedArray。每一個數組都有專門的元素類型。

類數組對象 vs 數組

JavaScript 中的某些對象 - 特別是在 DOM 中 - 雖然它們不是真正的數組,可是他們看起來像數組。能夠本身建立類數組的對象:

const arrayLike = {};
arrayLike[0] = 'a';
arrayLike[1] = 'b';
arrayLike[2] = 'c';
arrayLike.length = 3;

該對象具備 length 並支持索引元素訪問(就像數組!),但它的原型上缺乏數組方法,如 forEach。儘管如此,仍然能夠調用數組泛型:

Array.prototype.forEach.call(arrayLike, (value, index) => {
  console.log(`${ index }: ${ value }`);
});
// This logs '0: a', then '1: b', and finally '2: c'.

這個代碼工做原理以下,在類數組對象上調用數組內置的 Array.prototype.forEach。可是,這比在真正的數組中調用 forEach 慢,引擎數組的 forEach 在 V8 中是高度優化的。若是你打算在這個對象上屢次使用數組內置函數,能夠考慮先把它變成一個真正的數組:

const actualArray = Array.prototype.slice.call(arrayLike, 0);
actualArray.forEach((value, index) => {
  console.log(`${ index }: ${ value }`);
});
// This logs '0: a', then '1: b', and finally '2: c'.

爲了後續的優化,進行一次性轉換的成本是值得的,特別是若是您計劃在數組上執行大量操做。

例如,arguments 對象是類數組的對象。能夠在其上調用數組內置函數,可是這樣的操做將不會被徹底優化,由於這些優化只針對真正的數組。

const logArgs = function() {
  Array.prototype.forEach.call(arguments, (value, index) => {
    console.log(`${ index }: ${ value }`);
  });
};
logArgs('a', 'b', 'c');
// This logs '0: a', then '1: b', and finally '2: c'.

ES2015 的 rest 參數在這裏頗有幫助。它們產生真正的數組,能夠優雅的代替相似數組的對象 arguments

const logArgs = (...args) => {
  args.forEach((value, index) => {
    console.log(`${ index }: ${ value }`);
  });
};
logArgs('a', 'b', 'c');
// This logs '0: a', then '1: b', and finally '2: c'.

現在,沒有理由直接使用對象 arguments

一般,儘量避免使用數組類對象,應該使用真正的數組。

避免多態

若是您的代碼須要處理包含多種不一樣元素類型的數組,則可能會比單個元素類型數組要慢,由於你的代碼要對不一樣類型的數組元素進行多態操做。

考慮如下示例,其中使用了各類元素種類調用。(請注意,這不是本機 Array.prototype.forEach,它具備本身的一些優化,這些優化不一樣於本文中討論的元素種類優化。)

const each = (array, callback) => {
  for (let index = 0; index < array.length; ++index) {
    const item = array[index];
    callback(item);
  }
};
const doSomething = (item) => console.log(item);

each([], () => {});

each(['a', 'b', 'c'], doSomething);
// `each` is called with `PACKED_ELEMENTS`. V8 uses an inline cache
// (or 「IC」) to remember that `each` is called with this particular
// elements kind. V8 is optimistic and assumes that the
// `array.length` and `array[index]` accesses inside the `each`
// function are monomorphic (i.e. only ever receive a single kind
// of elements) until proven otherwise. For every future call to
// `each`, V8 checks if the elements kind is `PACKED_ELEMENTS`. If
// so, V8 can re-use the previously-generated code. If not, more work
// is needed.

each([1.1, 2.2, 3.3], doSomething);
// `each` is called with `PACKED_DOUBLE_ELEMENTS`. Because V8 has
// now seen different elements kinds passed to `each` in its IC, the
// `array.length` and `array[index]` accesses inside the `each`
// function get marked as polymorphic. V8 now needs an additional
// check every time `each` gets called: one for `PACKED_ELEMENTS`
// (like before), a new one for `PACKED_DOUBLE_ELEMENTS`, and one for
// any other elements kinds (like before). This incurs a performance
// hit.

each([1, 2, 3], doSomething);
// `each` is called with `PACKED_SMI_ELEMENTS`. This triggers another
// degree of polymorphism. There are now three different elements
// kinds in the IC for `each`. For every `each` call from now on, yet
// another elements kind check is needed to re-use the generated code
// for `PACKED_SMI_ELEMENTS`. This comes at a performance cost.

內置方法(如 Array.prototype.forEach)能夠更有效地處理這種多態性,所以在性能敏感的狀況下考慮使用它們而不是用戶庫函數。

V8 中單態與多態的另外一個例子涉及對象形狀(object shape),也稱爲對象的隱藏類。要了解更多,請查看 Vyacheslav 的文章

調試元素種類

找出一個給定的對象的「元素種類」,可使用一個調試版本 d8(參見「從源代碼構建」),並運行:

$ out.gn/x64.debug/d8 --allow-natives-syntax

這將打開 d8 REPL 中的特殊函數,如 %DebugPrint(object)。輸出中的「元素」字段顯示您傳遞給它的任何對象的「元素種類」。

d8> const array = [1, 2, 3]; %DebugPrint(array);
DebugPrint: 0x1fbbad30fd71: [JSArray]
 - map = 0x10a6f8a038b1 [FastProperties]
 - prototype = 0x1212bb687ec1
 - elements = 0x1fbbad30fd19 <FixedArray[3]> [PACKED_SMI_ELEMENTS (COW)]
 - length = 3
 - properties = 0x219eb0702241 <FixedArray[0]> {
    #length: 0x219eb0764ac9 <AccessorInfo> (const accessor descriptor)
 }
 - elements= 0x1fbbad30fd19 <FixedArray[3]> {
           0: 1
           1: 2
           2: 3
 }
[…]

請注意,「COW」 表示寫時複製,這是另外一個內部優化。如今不要擔憂 - 這是另外一個博文的主題!

調試版本中可用的另外一個有用的標誌是 --trace-elements-transitions。啓用它讓 V8 在任何元素髮生類型轉換時通知您。

$ cat my-script.js
const array = [1, 2, 3];
array[3] = 4.56;

$ out.gn/x64.debug/d8 --trace-elements-transitions my-script.js
elements transition [PACKED_SMI_ELEMENTS -> PACKED_DOUBLE_ELEMENTS] in ~+34 at x.js:2 for 0x1df87228c911 <JSArray[3]> from 0x1df87228c889 <FixedArray[3]> to 0x1df87228c941 <FixedDoubleArray[22]>
相關文章
相關標籤/搜索