JavaScript 在 V8 中的元素種類及效能最佳化

justjavac發表於2017-09-22

原文:“Elements kinds” in V8

JavaScript 物件可以具有與它們相關聯的任意屬性。物件屬性的名稱可以包含任何字元。JavaScript 引擎可以進行最佳化的一個有趣的例子是當屬性名是純數字時,一個特例就是陣列索引的屬性。

在 V8 中,如果屬性名是數字(最常見的形式是 Array 建構函式生成的物件)會被特殊處理。儘管在許多情況下,這些數字索引屬性的行為與其他屬性一樣,V8 選擇將它們與非數字屬性分開儲存以進行最佳化。在引擎內部,V8 甚至給這些屬性一個特殊的名稱:元素。物件具有對映到值的屬性,而陣列具有對映到元素的索引。

儘管這些內部結構從未直接暴露給 JavaScript 開發人員,但它們解釋了為什麼某些程式碼模式比其他程式碼模式更快。

常見的元素種類

執行 JavaScript 程式碼時,V8 會跟蹤每個陣列所包含的元素。這些資訊可以幫助 V8 最佳化陣列元素的操作。例如,當您在陣列上呼叫 reducemapforEach 時,V8 可以根據陣列包含哪些元素來最佳化這些操作。

拿這個陣列舉例:

const array = [1, 2, 3];

它包含什麼樣的元素?如果你使用 typeof 運算子,它會告訴你陣列包含 numbers。在語言層面,這就是你所得到的:JavaScript 不區分整數,浮點數和雙精度 - 它們只是數字。然而,在引擎級別,我們可以做出更精確的區分。這個陣列的元素是 PACKED_SMI_ELEMENTS。在 V8
中,術語 Smi 是指用於儲存小整數的特定格式。(後面我們會在 PACKED 部分中說明。)

稍後在這個陣列中新增一個浮點數將其轉換為更通用的元素型別:

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]>

相關文章