簡介
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 位元帶正負號整數的數值。
陣列
為了處理大型和稀疏陣列,內部有兩種陣列儲存空間類型:
- 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) 實際上具有兩種不同的即時 (JIT) 編譯器:
- 「完整」編譯器:可為任何 JavaScript 產生優質程式碼
- 最佳化編譯器:可為大多數 JavaScript 產生優質程式碼,但編譯時間較長。
完整編譯器
在 V8 中,完整編譯器會對所有程式碼執行,並盡快開始執行程式碼,快速產生良好但非最佳的程式碼。這個編譯器在編譯時間幾乎不會假設類型,而是預期變數類型在執行階段可以且會變更。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 適合使用單態的原因)。
您可以使用 V8 引擎的獨立「d8」版本,記錄最佳化的內容:
d8 --trace-opt primes.js
(這會將最佳化函式的名稱記錄到 stdout)。
不過,並非所有函式都能最佳化,某些功能會阻止最佳化編譯器在特定函式上執行 (「bail-out」)。尤其是最佳化編譯器目前會略過含有 try {} catch {} 區塊的函式!
因此
- 如果使用 try {} catch {} 區塊,請將效能敏感的程式碼放入巢狀函式: ```js function perf_sensitive() { // Do performance-sensitive work here }
try { perf_sensitive() } catch (e) { // Handle exceptions here } ```
我們會在最佳化編譯器中啟用 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,但請務必著重於最佳化自己的演算法!