V8 中的 JavaScript 效能提示

Chris Wilson
Chris Wilson

簡介

Daniel Clifford 舉辦了 Google I/O 大會的精彩演講,掌握在 V8 中改善 JavaScript 效能的提示與秘訣。Daniel 鼓勵我們「更快地滿足需求」,仔細分析 C++ 和 JavaScript 之間的效能差異,然後謹慎編寫 JavaScript 程式碼。本文將摘錄 Daniel 演講重點摘要,本文也會隨著成效指引異動一併更新。

最重要的建議

請務必將任何成效建議化為具體的脈絡。效能建議常讓人上癮,有時若先專注提供實用的建議,可能會分心忽略真正的問題。您必須仔細瞭解自家網頁應用程式的效能,在著重效能的秘訣之前,建議您運用 PageSpeed 等工具分析程式碼,然後提高分數。這有助於避免提前最佳化。

如要在網路應用程式中達到良好效能,最好的基本建議是:

  • 預先做好準備,以免發生 (或發現) 問題
  • 接著,找出並瞭解問題的根本原因
  • 最後,請修正重要資料

為完成這些步驟,請務必瞭解 V8 如何最佳化 JS,以便您編寫 JS 執行階段設計程式碼。同時,也請務必瞭解可用的工具及可提供哪些用途。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 位元帶正負號整數表示的數值。

陣列

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

  • 快速元素:精簡金鑰集的線性儲存空間
  • 字典元素:以雜湊資料表儲存的格式

最好不要導致陣列儲存體從某種類型轉換。

因此

  • 針對陣列使用開頭為 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 產生適當的程式碼
  • 「Optimization」編譯器,可為大多數 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 {} 擷取 {} 區塊來測試函式!

因此

  • 如果嘗試 {} 擷取 {} 區塊,請將效能敏感程式碼放入巢狀函式中: ```js function perf_sensitive() { // 在此執行效能敏感工作 }

try { perf_sensitive() } 擷取 (e) { // 處理例外狀況 } ```

我們在最佳化編譯器中啟用 try/catch 區塊,此指南日後可能會變更。您可以透過搭配上述 d8 使用「--trace-opt」來檢視最佳化編譯器如何轉換功能,這樣即可進一步瞭解哪些功能已啟動:

d8 --trace-opt primes.js

最佳化

最後,這個編譯器執行的最佳化作業屬於推測性質,有時並未失敗,且會遭到撤回。「取消最佳化」程序會捨棄經過最佳化的程式碼,並在「完整版」編譯器程式碼中恢復執行適當的程式碼。重新最佳化作業之後可能會再次觸發,但短期內,執行速度會變慢。特別是,若在函式最佳化後造成變數的隱藏類別發生變更,將會導致此最佳化作用發生。

因此

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

您可以像其他最佳化一樣,取得 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 的方式,但也請務必專注於最佳化自己的演算法!

參考資料