【譯】JavaScript工做原理:V8編譯器的優化

原文連接: https://blog.logrocket.com/ho...

原文標題:How JavaScript works: Optimizing the V8 compiler for efficiencyjavascript

本文首發於公衆號:符合預期的CoyPan前端

理解JavaScript的工做原理是寫出高效JavaScript代碼的關鍵。java

忘記那些可有可無的毫秒級改進:錯誤地使用對象屬性可能致使簡單的一行代碼速度下降7倍。node

考慮到JavaScript在軟件堆棧全部級別中的廣泛性,即便不是全部級別的基礎設施,也可能會出現微不足道的減速,而不只僅是網站的菜單動畫。chrome

有許多的方法來編寫高效的JavasScript代碼,但在這篇文章裏面,咱們將着重介紹編譯器友好的優化方法,這意味着源代碼使編譯器優化變得簡單有效。編程

咱們將把討論範圍縮小到V8,即支持electron、node.js和google chrome的JavaScript引擎。爲了理解編譯器友好的優化,咱們首先須要討論JavaScript是如何編譯的。後端

JavaScript在V8中的執行能夠分爲三個階段:緩存

  • 源代碼到抽象語法樹:解析器將源代碼生成抽象語法樹(AST)
  • 抽象語法樹到字節碼:V8的解釋器Ignition從抽象語法樹生成字節碼。請注意,生成字節碼這一步在2017年之前是沒有的。
  • 字節碼到機器碼:V8的編譯器TurboFan從字節碼生成一個圖,用高度優化的機器代碼替換字節碼的部分。

第一個階段超出了本文的範圍,可是第二個和第三個階段對編寫優化的JavaScript有直接的影響。網絡

咱們將討論這些優化方法以及代碼如何利用(或濫用)這些優化。經過了解JavaScript執行的基礎知識,您不只能夠理解這些性能方面的建議,還能夠學習如何發現本身的一些優化點。框架

實際上,第二和第三階段是緊密耦合的。這兩個階段在即時(just-in-time,JIT)範式中運行。爲了理解JIT的重要性,咱們將研究之前將源代碼轉換爲機器代碼的方法。

Just-in-Time (JIT) 範式

爲了執行任意一段程序,計算機必須將源代碼轉換成機器能夠運行的代碼。

有兩種方法能夠進行轉換。

第一種選擇是使用解釋器。解釋器能夠有效地逐行翻譯和執行。

第二種方法是使用編譯器。編譯器在執行以前當即將全部源代碼轉換爲機器語言。

下面,咱們將闡述兩種方法的優勢和缺點。

解釋器的優勢、缺點

解釋器使用read-eval-print loop (REPL,交互式解釋器)的方式工做 —— 這種方式有許多的優勢:

  • 易於實現和理解
  • 及時反饋
  • 更合適的編程環境

然而,這些好處是以緩慢執行爲代價的:

(1)eval的開銷,而不是運行機器代碼。

(2)沒法跨程序的對各個部分進行優化。

更正式地說,解釋器在處理不一樣的代碼段時不能識別重複的工做。若是你經過解釋器運行同一行代碼100次,解釋器將翻譯並執行同一行代碼100次,沒有必要地從新翻譯了99次。

總結一下,解釋器簡單、啓動快,可是執行慢。

編譯器的優勢、缺點

編譯器會在執行前翻譯全部的源代碼。

隨着複雜性的增長,編譯器能夠進行全局優化(例如,爲重複的代碼行共享機器代碼)。這爲編譯器提供了比解釋器惟一的優點 —— 更快的執行時間。

總結一下,編譯器是複雜的、啓動慢,可是執行快。

即時編譯(JIT)

即時編譯器嘗試結合瞭解釋器和編譯器的優勢,使代碼轉換和執行都變得更快。

基本思想是避免重複轉換。首先,探查器會經過解釋器先跑一遍代碼。在代碼執行期間,探查器會跟蹤運行幾回的熱代碼段和運行不少次的熱代碼段。

JIT將熱代碼片斷髮送給基線編譯器,儘量的複用編譯後的代碼。

JIT同時將熱代碼片斷髮送給優化編譯器。優化編譯器使用解釋器收集的信息來進行假設,而且基於這些假設進行優化(例如,對象屬性老是以特定的順序出現)。

可是,若是這些假設無效,優化編譯器將執行 去優化,丟棄優化的代碼。

優化和去優化的過程是昂貴的。由此產生了一類JavaScript的優化方法,下面將詳細描述。

JIT須要存儲優化的機器代碼和探查器的執行信息等,天然會引入內存開銷。儘管這一點沒法經過優化的JavaScript來改善,但激發了V8的解釋器。

V8的編譯

V8的解釋器和編譯器執行如下功能:

  • 解釋器將抽象語法樹轉換爲字節碼。字節碼隊列隨後會被執行,而且經過內聯緩存收集反饋。這些反饋會被解釋器自己用於隨後的解析,同時,編譯器會利用這些反饋來作推測性的優化。
  • 編譯器根據反饋將字節碼轉換爲特定於體系結構的機器碼,從而推測性地優化字節碼。

V8的解釋器 - Ignition

JIT編譯器顯示了開銷內存消耗。Ignition經過實現三個目標來解決這個問題:減小內存使用、減小啓動時間和下降複雜性。

這三個目標都是經過將AST轉換爲字節碼並在程序執行期間收集反饋來實現的。

  • 字節碼被當作源代碼對待,省去了在編譯期間從新解析JavaScript的須要。這意味着使用字節碼,TurboFan的去優化過程再也不須要原始的代碼了。
  • 做爲基於程序執行反饋的優化示例,內聯緩存容許V8優化對具備相同類型參數的函數的重複調用。具體來講,內聯緩存存儲函數的輸入類型。類型越少,須要的類型檢查就越少。減小類型檢查的數量能夠顯著提升性能。

AST和字節碼都會暴露給TurboFan。

V8的編譯器 - TurboFan

在2008年發佈時,V8引擎最初直接將源代碼編譯爲機器代碼,跳過了中間字節碼錶示。在發佈時,V8就比競爭對手快了10倍。

然而,到今天,TurboFan接受了Ignition的字節碼,比它發佈的時候快了10倍。V8的編譯器通過了一系列的迭代:

  • 2008 – Full-Codegen

    • 具備隱藏類和內聯緩存,快速遍歷AST的編譯器
    • 缺點:無優化的即時編譯
  • 2010 – Crankshaft

    • 使用類型反饋和去優化,優化即時編譯器。
    • 缺點: 不能擴展到現代JavaScript,嚴重依賴去優化,有限的靜態類型分析,與Codegen緊密耦合,高移植開銷
  • 2015 – TurboFan

    • 用類型和範圍分析優化即時編譯器

根據Google慕尼黑技術講座(Titzer,3月16號),TurboFan優化了峯值性能、靜態類型信息使用、編譯器前端、中間和後端分離以及可測試性。最終沉澱出一個關鍵的貢獻:"節點海"。

在節點海中,節點表示計算,變表示依賴關係。

與控制流圖(CFG)不一樣的是,節點海能夠放寬大多數操做的評估順序。與CGF同樣,有狀態操做的控制邊和效果邊在須要時會約束執行順序。

Titzer進一步完善了這個定義,使之成爲一個節點湯,其中控制流子圖進一步放寬。這提供了許多優勢—例如,這避免了冗餘代碼的消除。

經過自下而上或自上而下的圖轉換,圖縮減被應用於這一系列節點。

TurboFan遵循4個步驟將字節碼轉換爲機器碼。請注意,如下管道中的優化是根據Ignition的反饋執行的。

  • 將程序表示爲JavaScript操做符。(例如:JSADD)
  • 將程序表示爲中間運算符。(虛擬機級別的操做符;不可知的數字表示,例如:NumberAdd)
  • 將程序表示爲機器操做符。(與機器操做符相對應,例如:Int32Add)
  • 使用順序約束安排執行順序。建立一個傳統的控制流圖。

TurboFan的在線JIT風格的編譯和優化意味着 V8從源代碼到機器代碼的轉換 結束了。

如何優化你的JavaScript

TurboFan的優化經過減輕糟糕的JavaScript的影響來提升JavaScript的網絡性能。然而,瞭解這些優化能夠提供進一步的加速。

下面是利用V8中的優化來提升性能的7個技巧。前四個重點是減小去優化。

Tip1: 在構造函數中聲明對象屬性

更改對象屬性會產生新的隱藏類。以google i/o 2012中的如下示例爲例。

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

var p1 = new Point(11, 22);  // hidden class Point created
var p2 = new Point(33, 44);

p1.z = 55;  // another hidden class Point created

正如你所見,p1和p2如今有不一樣的隱藏類了。這阻礙了TurboFan的優化嘗試:具體來講,任何接受Point對象的方法如今都是去優化的。

全部這些函數都使用兩個隱藏類從新優化。對對象形狀的任何修改都是如此。

Tip2: 保持對象屬性不變

更改對象屬性的順序會致使新的隱藏類,由於對象形狀中是包含順序的。

const a1 = { a: 1 };  # hidden class a1 created
a1.b = 3;

const a2 = { b: 3 };  # different hidden class a2 created
a2.a = 1;

上面的代碼中,a1和a2有不一樣的隱藏類。修復順序容許編譯器重用同一個隱藏類。由於添加的字段(包括順序)用於生成隱藏類的id

Tip3:修復函數參數類型

函數根據特定參數位置的值類型更改對象形狀。若是此類型發生更改,則函數將去優化並從新優化。

在看到四種不一樣的對象形狀後,該函數會變成megamorphic,TurboFan將不會再嘗試優化這個函數。

看下面這個例子:

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

add(1, 2);  # monomorphic
add("a", "b");  # polymorphic
add(true, false);
add([], []);
add({}, {});  # megamorphic

第9行事後,TurboFan將不會再優化add這個函數。

Tip4:在腳本做用域中聲明類

不要在函數做用域中聲明類。如下面這個例子爲例:

function createPoint(x, y) {
  class Point {
    constructor(x, y) {
      this.x = x;
      this.y = y;
    }
  }
  return new Point(x, y);
}

function length(point) {
  ...
}

每一次createPoint這個函數被調用的時候,一個新的Point原型會被建立。

每個新的原型都對應着一個新的對象形狀,因此每一次length函數都會看到一個新的point的對象形狀。

跟以前同樣,當看到4個不一樣的對象形狀的時候,函數會變得megamorphic,TurboFan將不會再嘗試優化

length函數。

在腳本做用域中聲明class Point,咱們能夠避免每一次調用createPoint的時候,生成不一樣的對象形狀。

下一個tip是V8引擎裏的奇淫巧技。

Tip5:使用for…in

這是V8引擎中的一個怪異行爲。這一特性以前包含在最初的Crankshaft裏面,後來被移植到了Ignition and Turbofan.

for…in循環比函數迭代、帶箭頭函數的函數迭代和for循環中的object.keys快4-6倍。

接下來兩個Tip是對以前兩種說法的反駁。因爲現代V8引擎的改變,這兩種說法已經不成立了。

Tip6:無關字符不影響性能

Crankshaft過去是使用一個函數的字節數來決定是否內聯一個函數的。而TurboFan是創建在AST上的,他使用AST節點的數量來決定函數的大小。

所以,無關的字符,好比空白,註釋,變量名長度,函數簽名等,不會影響函數的性能。

Tip7:Try/catch/finally 不是毀滅性的

Try代碼塊之前容易出現高昂的優化-去優化的週期。現在,當在Try塊中調用函數時,turbofan再也不顯示出顯著的性能影響。

結論

總之,優化方法一般集中在減小去優化和避免不可優化的megamorphic函數上。

經過對V8引擎框架的理解,咱們還能夠推斷出上面沒有列出的其餘優化方法,並儘量重用方法來利用內聯。如今您已經瞭解了JavaScript編譯及其對平常JavaScript使用的影響。


圖片描述

相關文章
相關標籤/搜索