[譯] 瞭解“多型”JSON 資料的效能問題

cyril_lee發表於2019-03-26

結構相同但值型別不同的物件如何對 JavaScript 效能產生驚人的影響

照片由 [Markus Spiske](https://unsplash.com/@markusspiske?utm_source=medium&utm_medium=referral) 釋出於 [Unsplash](https://unsplash.com?utm_source=medium&utm_medium=referral)

當我做一些底層效能優化以用於渲染 Wolfram Cloud notebook 時,我注意到一個非常奇怪的問題,就是函式會因為處理浮點數進入較慢的執行路徑,即使所有傳入的資料都是整數的情況下也會是這樣。具體來說,單元格計數器被 JavaScript 引擎視為浮點數,這大大減慢了大型 notebook 的渲染速度(至少在 Chrome 裡面是這樣)。

我們將單元格計數器 (由 CounterAssignmentsCounterIncrements 進行的定義) 表示為一個整數陣列,它具有從屬性名到索引的一個獨立的對映。這比每組計數器儲存為一個字典形式更為高效。舉個例子,它並不是下面的這種格式

{Title: 1, Section: 3, Input: 7}
複製程式碼

而是我們會儲存一個陣列

[1, 3, 7]
複製程式碼

然後再保持一個從值到索引的獨立的(全域性)對映關係

{Title: 0, Section: 1, Input: 2}
複製程式碼

當我們渲染 notebook 時,每個單元格都保留自己當前計數器值的副本,執行自己的賦值和增量(如果有的話),並將新陣列傳遞給下一個單元格。

我發現—-至少在有些時候--V8(也就是 Chrome 和 Node.js 的 JS 引擎)將數值陣列視為它們包含的是浮點數。這會在很多操作上降低效率,因為浮點數的記憶體佈局不如(小)整數高效。這很奇怪,因為陣列裡面除了 Smi (在正負 31 位之間的整數,也就是從 -2³⁰ 到 2³⁰-1)不包含任何其他的東西。

我找到一個解決辦法,就是在從 JSON 物件讀取資料之後到將他們放到計數陣列之前,“強制”對所有的值進行“值 | 0”的按位運算轉變成整數(即使他們已經是 JSON 資料中的整數)。然而雖然我有了這個解決辦法,但是我還是不能完全理解為什麼它會起作用--直到最近...

說明

Mathias BynensBenedikt MeurerAgentConf 的分享 JavaScript 引擎基礎:好的,壞的和醜陋的終於點醒了我:這都是關於 JS 引擎中物件的內部實現,以及每個物件如何連結到某個結構

JS 引擎會跟蹤物件上定義的屬性名稱,然後每當新增或刪除屬性時,隱式使用不同的結構。相同結構的物件會在記憶體的相同位置有相同屬性(相對於物件地址而言),允許引擎顯著地加速屬性的訪問並減少單個物件例項的記憶體樣板(他們不必自己維護一本完整的屬性字典)。

我之前不知道的是,結構也區分了不同型別的屬性值。特別是,具有小整數值的屬性意味著與(部分時候)包含其他數值的屬性不同的結構。比如在

const b = {};
b.x = 2;
b.x = 0.2;
複製程式碼

結構轉換髮生在二次賦值時,從一個具有 Smi 值的屬性 x 轉變到一個可能是任意雙精度值的屬性 x。之前的結構隨後被“棄用”,不再繼續使用。就算其他物件沒有使用非 smi 的值,但是隻要它的屬性 x 一旦被使用就會被切換到其他狀態。這個幻燈片對此總結的很好。

所以這正是我們使用計數器的情況:CounterAssignments 和 CounterIncrements 定義來自 JSON 值的資料就像這樣

{"type": "MInteger", "value": 2}
複製程式碼

但是我們也會有資料像是這樣

{"type": "MReal", "value": 0.2}
複製程式碼

在筆記本的其他部分。即使沒有將 MReal 物件用於計數器,這些物件的存在本身導致所有 MInteger 物件也會改變它們的結構。將它們的值複製到計數器陣列中然後也會導致這些陣列切換到效能較低的狀態。

檢查 Node.js 中的內部型別

我們可以使用 natives syntax 來檢查 V8 內部的內容。這是通過命令列引數 --allow-natives-syntax 來啟用的。特殊函式的完整列表還沒有官方文件,但是已經有非官方列表。而且還有一個 v8-natives 包可以更方便的訪問。

在我們的例子中,我們可以使用 %HasSmiElements 來確定指定的陣列是否具有 Smi 元素:

const obj = {};
obj.value = 1;
const arr1 = [obj.value, obj.value];
console.log(`arr1 has Smi elements: ${%HasSmiElements(arr1)}`);

const otherObj = {};
otherObj.value = 1.5;

const arr2 = [obj.value, obj.value];
console.log(`arr2 has Smi elements: ${%HasSmiElements(arr2)}`);
複製程式碼

執行此程式會輸出下面的內容:

$ node --allow-natives-syntax inspect-types.js
arr1 has Smi elements: true
arr2 has Smi elements: false
複製程式碼

在構造具有相同結構但具有浮點值的物件之後,使用原始物件(包含整數值)再次產生非 Smi 陣列。

在獨立示例上衡量其造成的影響

為了說明對效能的影響,讓我們使用以下 JS 程式(counters-smi.js):

function copyAndIncrement(arr) {
  const copy = arr.slice();
  copy[0] += 1;
  return copy;
}

function main() {
  const obj = {};
  obj.value = 1;
  let arr = [];
  for (let i = 0; i < 100; ++i) {
    arr.push(obj.value);
  }
  for (let i = 0; i < 10000000; ++i) {
    arr = copyAndIncrement(arr);
  }
}

main();
複製程式碼

我們首先構造一個從物件 obj 中提取的 100 個整數的陣列,然後我們呼叫 copyAndIncrement 一千萬次,它會建立一個陣列的副本,然後在副本中改變一個元素,然後返回新的陣列。這就是在渲染(體積很大的)notebook 時處理單個計數器時實質上發生的事。

讓我們稍微改變一下程式並在開頭加入如下程式碼(counters-float.js):

    const objThatSpoilsEverything = {};
    objThatSpoilsEverything.value = 1.5;
複製程式碼

僅僅這個物件的存在本身就將導致另一個物件改變其結構並減慢根據它的值構造的陣列的操作。

請注意,建立空物件後新增屬性與解析 JSON 字串具有相同的效果:

    const objThatSpoilsEverything = JSON.parse('{"value": 1.5}');
複製程式碼

現在比較這兩個程式的執行情況:

$ time node counters-smi.js
node counters-smi.js  0.87s user 0.11s system 103% cpu 0.951 total

$ time node counters-float.js
node counters-float.js  1.22s user 0.13s system 103% cpu 1.309 total
複製程式碼

這是使用 Node v11.9.0(執行 V8 版本 7.0.276.38-node.16)。但讓我們嘗試一下所有的主流 JS 引擎:

[譯] 瞭解“多型”JSON 資料的效能問題

$ npm i -g jsvu

$ jsvu

$ v8 -v
V8 version 7.4.221

$ spidermonkey -v
JavaScript-C66.0

$ chakra -v
ch version 1.11.6.0

$ jsc
複製程式碼

在 Chrome 中的 V8,在 Firefox 中的 SpiderMonkey,在 IE 和 Edge 中的 Chakra,在 Safari 中的 JavaScriptCore。

並不能理想測量整個過程的執行時間,但我們可以通過用 multitime 關注每個示例的 100 次執行的中位數來減少異常值(按隨機順序,在兩次執行之間休息 1 秒):

$ multitime -n 100 -s 1 -b examples.bat
===> multitime results
1: v8 counters-smi.js
            Mean        Std.Dev.    Min         Median      Max
real        0.767       0.014       0.738       0.765       0.812
user        0.669       0.012       0.643       0.666       0.705
sys         0.086       0.003       0.080       0.085       0.095

2: v8 counters-float.js
            Mean        Std.Dev.    Min         Median      Max
real        0.854       0.016       0.829       0.851       0.918
user        0.750       0.019       0.662       0.750       0.791
sys         0.088       0.004       0.082       0.087       0.107

3: spidermonkey counters-smi.js
            Mean        Std.Dev.    Min         Median      Max
real        1.378       0.024       1.355       1.372       1.538
user        1.362       0.011       1.346       1.360       1.408
sys         0.074       0.005       0.067       0.073       0.101

4: spidermonkey counters-float.js
            Mean        Std.Dev.    Min         Median      Max
real        1.406       0.021       1.385       1.400       1.506
user        1.389       0.011       1.374       1.387       1.440
sys         0.075       0.005       0.068       0.074       0.093

5: chakra counters-smi.js
            Mean        Std.Dev.    Min         Median      Max
real        2.285       0.051       2.193       2.280       2.494
user        2.359       0.044       2.291       2.354       2.560
sys         0.203       0.032       0.141       0.202       0.268

6: chakra counters-float.js
            Mean        Std.Dev.    Min         Median      Max
real        2.292       0.050       2.195       2.286       2.444
user        2.365       0.042       2.284       2.360       2.501
sys         0.207       0.031       0.141       0.209       0.277

7: jsc counters-smi.js
            Mean        Std.Dev.    Min         Median      Max
real        1.042       0.031       1.009       1.034       1.218
user        1.051       0.013       1.030       1.050       1.093
sys         0.336       0.013       0.319       0.333       0.394

8: jsc counters-float.js
            Mean        Std.Dev.    Min         Median      Max
real        1.041       0.025       1.012       1.038       1.246
user        1.054       0.012       1.032       1.056       1.099
sys         0.338       0.014       0.315       0.335       0.397
複製程式碼

這裡有幾點需要注意:

  • 僅在 V8 中,兩種方法之間存在著顯著差異(大約 0.08 秒或 10%)。

  • 在 Smi 和浮點數模式下,V8 都比其他所有的引擎更快。

  • 這裡獨立使用的 V8 比 Node 11.9(它使用的老版本的 V8)要快得多。我猜想這主要是因為最近的 V8 版本的常規效能改進(注意 Smi 和浮點數之間的差異是如何從 0.35s 減少到 0.08s 的),但與 V8 相比,Node 的其他一些開銷可能也有影響。

你可以看一下完整的測試檔案。所有測試均在 2013 年末 15 英寸款 MacBook Pro 上執行,執行 macOS 10.14.3,配備 2.6 GHz i7 CPU。

總結

V8 中的結構轉換可能會產生一些令人驚訝的效能影響。但通常您不必在實踐中擔心這個問題(主要是因為 V8 即使在“慢速”路徑上,也可能比其他所有引擎都表現得更快)。但是在一個高效能的應用程式中,最好記住“全域性”結構表的效果,因為應用程式的各個相互獨立的部分也可以相互影響。

如果您正在處理不受您控制的外部 JSON 資料,您可以使用按位 OR 將值“轉換”為整數,如值 | 0,這也將確保其內部表示是一個 Smi。

如果您可以直接定義 JSON 資料,那麼對於具有相同底層值型別的屬性僅使用相同的屬性名稱沒準是個好主意。例如,在我們的例子中這可能更好用

{"type": "MInteger", "intValue": 2}
{"type": "MReal", "realValue": 2.5}
複製程式碼

而不是在不同值型別的情況下都使用同一個屬性。換句話說:避免使用“多型”物件。

即使在實踐中 V8 場景下對效能的影響可以忽略不計,但是更深入的瞭解幕後發生的事情總會很有趣。就我個人來說,當我發現我一年前做的優化為什麼有效的時候我會感到特別開心。

有關更詳細的內容,這裡還有各個資料的連結:

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章