V8 中的 JavaScript 效能提示

Chris Wilson
Chris Wilson

簡介

Daniel Clifford 在 Google I/O 大會上發表精彩演講,分享提升 V8 中 JavaScript 效能的訣竅。Daniel 鼓勵我們「要求更快」- 仔細分析 C++ 和 JavaScript 之間的效能差異,並在編寫程式碼時留意 JavaScript 的運作方式。本文摘要列出 Daniel 演講的重點,我們也會隨著成效指南的變動更新這篇文章。

最重要的建議

提供任何成效建議時,請務必考量相關背景。成效建議很容易上癮,有時先專注於深入的建議,可能會讓你忽略真正的問題。您需要從整體角度檢視網頁應用程式的效能,在著重於這些效能提示之前,建議您使用 PageSpeed 等工具分析程式碼,並提高分數。這有助於避免過早最佳化。

如要讓網頁應用程式獲得良好的效能,建議您採取以下做法:

  • 在發生 (或發現) 問題之前做好準備
  • 接著找出並瞭解問題的癥結
  • 最後,修正重要問題

為了完成這些步驟,您必須瞭解 V8 如何最佳化 JavaScript,這樣才能在編寫程式碼時考量 JavaScript 執行階段設計。您也應瞭解可用的工具,以及這些工具如何協助您。Daniel 在演講中進一步說明如何使用開發人員工具;本文件僅擷取 V8 引擎設計中最重要的幾個要點。

接下來,我們來看看 V8 的提示!

隱藏的課程

JavaScript 的編譯時間型別資訊有限:型別可以在執行階段變更,因此在編譯期間推論 JS 型別的成本自然很高。這可能讓您質疑 JavaScript 效能如何能達到接近 C++ 的程度。不過,V8 在執行階段為物件在內部建立隱藏類型,因此具有相同隱藏類別的物件可以使用相同的最佳化產生程式碼。

例如:

function Point(x, y) {
  this.x = x;
  this.y = y;
}

var p1 = new Point(11, 22);
var p2 = new Point(33, 44);
// At this point, p1 and p2 have a shared hidden class
p2.z = 55;
// warning! p1 and p2 now have different hidden classes!```

在物件例項 p2 新增額外成員「.z」之前,p1 和 p2 在內部都具有相同的隱藏類別,因此 V8 可以為操控 p1 或 p2 的 JavaScript 程式碼產生單一版本的最佳化組合。您越能避免隱藏類別產生差異,成效就越好。

因此

  • 在建構函式中初始化所有物件成員 (以便執行個體日後不會變更類型)
  • 一律按照相同順序初始化物件成員

Numbers

當類型可能變更時,V8 會使用標記來有效地表示值。V8 會根據您使用的值,推斷要處理的數字類型。一旦 V8 做出這項推論,就會使用標記來有效呈現值,因為這些類型可能會動態變更。不過,更改這些類型代碼有時會產生成本,因此建議您一律使用數字類型,並在適當情況下使用 31 位元帶符號整數。

例如:

var i = 42;  // this is a 31-bit signed integer
var j = 4.2;  // this is a double-precision floating point number```

因此

  • 請優先使用可用 31 位元帶號整數表示的數值。

陣列

為了處理大型和稀疏陣列,內部有兩種陣列儲存類型:

  • Fast Elements:用於緊湊鍵組的線性儲存空間
  • 字典元素:否則為雜湊表儲存

最好不要讓陣列儲存空間從一種型別切換為另一種型別。

因此

  • 使用從 0 開始的連續索引鍵建立陣列
  • 不要預先將大型陣列 (例如 > 64K 個元素) 預留至最大大小,而是隨著使用量增加
  • 請勿刪除陣列中的元素,尤其是數值陣列
  • 請勿載入未初始化的元素或已刪除的元素:
for (var b = 0; b < 10; b++) {
  a[0] |= b;  // Oh no!
}
//vs.
a = new Array();
a[0] = 0;
for (var b = 0; b < 10; b++) {
  a[0] |= b;  // Much better! 2x faster.
}

此外,陣列的隱藏類別會追蹤元素類型,而陣列中只包含雙精度的陣列會進行解箱 (導致隱藏類別變更)。不過,如果不小心操作陣列,可能會因箱化和解箱而造成額外工作,例如

var a = new Array();
a[0] = 77;   // Allocates
a[1] = 88;
a[2] = 0.5;   // Allocates, converts
a[3] = true; // Allocates, converts```

效率低於:

var a = [77, 88, 0.5, true];

因為在第一個範例中,個別指派會依序執行,而 a[2] 的指派會導致陣列轉換為未包裝的雙精度陣列,但 a[3] 的指派會導致陣列重新轉換為可包含任何值 (數字或物件) 的陣列。在第二種情況中,編譯器會知道文字值中所有元素的類型,因此可以事先判斷隱藏的類別。

  • 針對小型固定大小陣列,使用陣列常值進行初始化
  • 預先分配小型陣列 (小於 64k) 的正確大小,再使用這些陣列
  • 請勿在數值陣列中儲存非數值 (物件)
  • 如果您在沒有字面值的情況下進行初始化,請小心不要導致小型陣列重新轉換。

JavaScript 編譯

雖然 JavaScript 是相當動態的語言,而且最初的實作方式是使用解譯器,但現代的 JavaScript 執行階段引擎會使用編譯。事實上,V8 (Chrome 的 JavaScript) 有兩種不同的 Just-In-Time (JIT) 編譯器:

  • 「Full」編譯器,可為任何 JavaScript 產生良好的程式碼
  • 最佳化編譯器,可為大多數 JavaScript 產生優質程式碼,但編譯時間較長。

完整編譯器

在 V8 中,完整編譯器會在所有程式碼上執行,並盡快開始執行程式碼,快速產生良好但非最佳的程式碼。這個編譯器在編譯時幾乎不會假設任何類型,而是預期變數類型可以在執行階段變更,且會變更。由完整編譯器產生的程式碼會使用內嵌快取 (IC),在程式執行期間精進類型相關知識,隨時提高效率。

內嵌快取的目標是透過快取作業的類型相關程式碼,有效地處理類型;當程式碼執行時,會先驗證類型假設,然後使用內嵌快取來縮短作業時間。不過,這也代表接受多種類型的作業效能會降低。

因此

  • 建議使用單形態作業,而非多形態作業

如果輸入內容的隱藏類別一律相同,作業就會是單形態的,否則就是多形態的,也就是說,在對作業進行不同呼叫時,部分引數可能會變更類型。舉例來說,這個範例中的第二個 add() 呼叫會導致多態性:

function add(x, y) {
  return x + y;
}

add(1, 2);      // + in add is monomorphic
add("a", "b");  // + in add becomes polymorphic```

最佳化編譯器

與完整編譯器並行,V8 會使用最佳化編譯器重新編譯「熱門」函式 (也就是執行多次的函式)。這個編譯器會使用類型意見回饋,加快編譯代碼的速度。事實上,它會使用我們剛才討論的 IC 類型!

在最佳化編譯器中,系統會將作業推測內嵌 (直接放置在呼叫作業的位置)。這麼做雖然會增加記憶體占用空間,但也能加快執行速度,並啟用其他最佳化功能。單形函式和建構函式可以完全內嵌 (這也是在 V8 中使用單形的好處)。

您可以使用 V8 引擎的獨立「d8」版本,記錄要進行最佳化的項目:

d8 --trace-opt primes.js

(這會將最佳化函式的名稱記錄到 stdout)。

不過,並非所有函式都能進行最佳化,因為某些功能會防止最佳化編譯器在特定函式上執行 (稱為「退出」)。特別是,目前最佳化編譯器會在含有 try {} catch {} 區塊的函式中退出!

因此

  • 如果您有 try {} catch {} 區塊,請將效能敏感的程式碼放入巢狀函式中: ```js function perf_sensitive() { // 在此處執行效能敏感的工作 }

try { perf_sensitive() } catch (e) { // 在此處處理例外狀況 } ```

由於我們會在最佳化編譯器中啟用 try/catch 區塊,因此這項指南日後可能會有所變動。您可以使用上述 d8 的「--trace-opt」選項,查看最佳化編譯器如何退出函式,這可提供有關退出函式的更多資訊:

d8 --trace-opt primes.js

反最佳化

最後,這個編譯器執行的最佳化作業是推測性的,有時會失敗,因此我們會退回。"deoptimization" 程序會捨棄最佳化程式碼,並在「完整」編譯器程式碼的正確位置恢復執行。系統可能會在稍後再次啟動重新最佳化,但短期內執行速度會變慢。特別是,在函式經過最佳化後,變數隱藏類別發生變化,就會導致這種不最佳化情形。

因此

  • 避免在函式最佳化後,隱藏函式中的類別變更

如同其他最佳化方式,您可以使用記錄標記,取得 V8 必須以非最佳化方式執行的函式記錄:

d8 --trace-deopt primes.js

其他 V8 工具

順帶一提,您也可以在啟動時將 V8 追蹤選項傳遞至 Chrome:

"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --js-flags="--trace-opt --trace-deopt"```

除了使用開發人員工具剖析外,您也可以使用 d8 進行剖析:

% out/ia32.release/d8 primes.js --prof

這項功能會使用內建的取樣分析工具,該工具會每毫秒取樣一次並寫入 v8.log。

摘要

請務必瞭解 V8 引擎如何與程式碼搭配運作,以便準備建構效能良好的 JavaScript。再次重申,基本建議如下:

  • 在發生 (或發現) 問題之前做好準備
  • 接著找出並瞭解問題的癥結
  • 最後,修正重要問題

也就是說,您必須先使用 PageSpeed 等其他工具,確認問題出在 JavaScript 中,然後可能先減少純 JavaScript (不含 DOM),再收集指標,然後使用這些指標找出瓶頸,並排除重要的瓶頸。希望 Daniel 的演講 (以及本文) 能讓您更瞭解 V8 如何執行 JavaScript,但也請務必著重於改善自己的演算法!

參考資料