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 開始的連續鍵
  • 請勿預先分配大型陣列 (例如超過 64, 000 個元素) 至其大小上限,而是隨時間增加而增加。
  • 請勿刪除陣列中的元素,尤其是數字陣列
  • 不要載入未初始化或刪除的元素:
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) 編譯器,事實上:

  • 完整可為任何 JavaScript 產生良好程式碼
  • 最佳化編譯器,可為大部分的 JavaScript 產生出色的程式碼,但編譯時間較長。

完整編譯器

在 V8 中,Full 編譯器會在所有程式碼上執行,並盡快開始執行程式碼,然後迅速產生良好但效果不佳的程式碼。這個編譯器在編譯期間幾乎沒有關於型別,因此會預期變數型別可以在執行階段發生,且會變更。Full 編譯器產生的程式碼會使用內嵌快取 (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 中是很好的主角)。

您可以使用獨立的「d8」記錄資料,將哪些資料最佳化版本更新:

d8 --trace-opt primes.js

(此動作會記錄最佳化函式的名稱至 stdout)。

不過,並非所有函式都能最佳化,因此有些功能會造成最佳化編譯器無法在指定函式 (「邊框結束」) 執行。特別是,最佳化編譯器目前以嘗試 {} 擷取 {} 區塊的函式執行效能問題!

因此

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

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

由於我們在最佳化編譯器中啟用 try/catch 區塊,因此本指南日後可能會改變。您可以使用「--trace-opt」選項中的 d8 選項,這樣就能進一步瞭解已啟用的函式:

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 的方式,但也請務必專注於最佳化自己的演算法!

參考資料