JIT 編譯器快速入門

腳板發表於2019-02-21

本文是 WebAssembly 系列文章的第二部分。如果你還沒有閱讀過前面的文章,我們建議你 從頭開始.

JavaScript 剛面世時執行速度是很慢的,而 JIT 的出現令其效能快速提升。那麼問題來了,JIT 是如何運作的呢?

JavaScript 在瀏覽器中的執行機制

作為一名開發者,當你向網頁中新增 JavaScript 程式碼的時候,你有一個目標和一個問題。

目標: 你想要告訴計算機做什麼。

問題: 你和計算機使用的是不同的語言。

你使用的是人類語言,而計算機使用的是機器語言。即使你不願承認,對於計算機來說 JavaScript 甚至其他高階程式語言都是人類語言。這些語言是為人類的認知設計的,而不是機器。

所以 JavaScript 引擎的作用就是將你使用的人類語言轉換成機器能夠理解的東西。

我認為這就像電影 降臨 里人類和外星人試圖互相交談的情節一樣。

一個人用原始碼示意,外星人以二進位制回應
一個人用原始碼示意,外星人以二進位制回應

在電影中,人類和外星人在嘗試交流的過程裡並不只是做逐字翻譯。這兩個群體對世界有不同的思考方式,人類和機器也是如此(我將在下一篇文章中詳細說明)。

既然這樣,那轉化是如何發生的呢?

在程式設計中,我們通常使用直譯器和編譯器這兩種方法將程式程式碼轉化為機器語言。

直譯器會在程式執行時對程式碼進行逐行轉義。

一個人正在白板前將程式碼翻譯成二進位制
一個人正在白板前將程式碼翻譯成二進位制

相反的是,編譯器會提前將程式碼轉義並儲存下來,而不是在執行時對程式碼進行轉義。

一個人拿著一頁翻譯後的二進位制程式碼
一個人拿著一頁翻譯後的二進位制程式碼

以上兩種轉化方式都各有優劣。

直譯器的優缺點

直譯器可以迅速開始工作。在執行程式碼之前,你不必等待所有的彙編步驟完成,只要開始轉義第一行程式碼就可以執行程式了。

因此,直譯器看起來自然很適用於 JavaScript 這類語言。對於 Web 開發者來說,能夠快速執行程式碼相當重要。

這就是各瀏覽器在初期使用 JavaScript 直譯器的原因。

但是當你重複執行同樣的程式碼時,直譯器的劣勢就顯現出來了。舉個例子,如果在迴圈中,你就不得不重複對迴圈體進行轉化。

編譯器的優缺點

編譯器的優缺點恰恰和直譯器相反。

使用編譯器在啟動時會花費多一些時間,因為它必須在啟動前完成編譯的所有步驟。但是在迴圈體中的程式碼執行速度更快,因為它不需要在每次迴圈時都進行編譯。

另一個不同之處在於編譯器有更多時間對程式碼進行檢視和編輯,來讓程式執行得更快。這些編輯我們稱為優化。

直譯器在程式執行時工作,因此它無法在轉義過程中花費大量時間來確定這些優化。

兩全其美的解決辦法 —— JIT 編譯器

為了解決直譯器在迴圈時重複編譯導致的低效問題,瀏覽器開始將編譯器混合進來。

不同瀏覽器的實現方式稍有不同,但基本思路是一致的。它們向 JavaScript 引擎新增了一個新的部件,我們稱之為監視器(又名分析器)。監視器會在程式碼執行時監視並記錄下程式碼的執行次數和使用到的型別。

起初,監視器只是通過直譯器執行所有操作。

監視器監控程式碼執行併發出解釋程式碼的訊號
監視器監控程式碼執行併發出解釋程式碼的訊號

如果一段程式碼執行了幾次,這段程式碼被稱為 warm code;當這段程式碼執行了很多次時,它就會被稱為 hot code。

基線編譯器

當一個函式執行了數次時,JIT 會將該函式傳送給編譯器編譯,然後把編譯結果儲存下來。

監視器發現一個函式執行了數次,示意應該將這段函式傳送給基線編譯器建立一個存根
監視器發現一個函式執行了數次,示意應該將這段函式傳送給基線編譯器建立一個存根

該函式的每一行都被編譯成一個“存根”,存根以行號和變數型別為索引(這很重要,我後面會解釋)。如果監視器監測到程式再次使用相同型別的變數執行這段程式碼,它將直接抽取出對應程式碼的編譯後版本。

這有助於加快程式的執行速度,但是像我說的,編譯器可以做得更多。只要花費一些時間,它能夠確定最高效的執行方式,即優化。

基線編譯器可以完成一些優化(我會在後續給出示例)。不過,為了不阻攔程式過久,它並不願意在優化上花費太多時間。

然而,如果這段程式碼執行次數實在太多,那就值得花費額外的時間對它做進一步優化。

優化編譯器

當一段程式碼執行的頻率非常高時,監視器會把它傳送給優化編譯器。然後得到另一個執行速度更快的函式版本並儲存下來。

監視器發現一段程式碼執行了更多遍,示意這段程式碼應該被全面優化
監視器發現一段程式碼執行了更多遍,示意這段程式碼應該被全面優化

為了得到執行速度更快的程式碼版本,優化編譯器會做一些假設。

舉例來說,如果它可以假設由特定建構函式建立的所有物件結構相同,即所有物件的屬性名相同,並且這些屬性的新增順序相同,然後它就可以基於這個進行優化。

優化編譯器會依據監視器監測程式碼執行時收集到的資訊做出判斷。如果在之前通過的迴圈中有一個值總是 true,它便假定這個值在後續的迴圈中也是 true。

但在 JavaScript 中沒有任何情況是可以保證的。你可能會先得到 99 個結構相同的物件,但第 100 個就有可能缺少一個屬性。

所以編譯後的程式碼在執行前需要檢查假設是否有效。如果有效,編譯後的程式碼即執行。但如果無效,JIT 就認為它做了錯誤的假設並銷燬對應的優化後程式碼。

監視器發現型別與期望不匹配,示意回到直譯器。優化器將得到的優化程式碼銷燬
監視器發現型別與期望不匹配,示意回到直譯器。優化器將得到的優化程式碼銷燬

程式會回退到直譯器或基線編譯器編譯的版本。這個過程被稱為去優化(或應急機制)。

通常優化編譯器會加快程式碼執行速度,但有時它們也會導致意外的效能問題。如果你的程式碼被不斷的優化和去優化,執行速度會比基線編譯版本更慢。

為了防止這種情況發生,許多瀏覽器新增了限制,以便在“優化-去優化”這類迴圈發生時打破迴圈。例如,當 JIT 嘗試了 10 次優化仍未成功時,就會停止當前優化。

優化示例: 型別專門化

優化的型別有很多,但我只演示其中一種以便你理解優化是如何發生的。優化編譯器最大的成功之一來自於型別專門化。

JavaScript 使用的動態型別系統在執行時需要多做一些額外的工作。例如下面這段程式碼:

function arraySum(arr) {
  var sum = 0;
  for (var i = 0; i < arr.length; i++) {
    sum += arr[i];
  }
}複製程式碼

執行迴圈中的 += 一步似乎很簡單。看起來你可以一步就得到計算結果,但由於 JavaScript 的動態型別,處理它所需要的步驟比你想象的多。

假定 arr 是一個存放 100 個整數的陣列。在程式碼執行幾次後,基線編譯器將為函式中的每個操作建立一個存根。sum += arr[i] 將會有一個把 += 依據整數加法處理的存根。

然而我們並不能保證 sumarr[i] 一定是整數。因為在 JavaScript 中資料型別是動態的,有可能在下一次迴圈中的 arr[i] 是一個字串。整數加法和字串拼接是兩個完全不同的操作,因此也會編譯成非常不同的機器碼。

JIT 處理這種情況的方法是編譯多個基線存根。一段程式碼如果是單態的(即總被同一種型別呼叫),將得到一個存根。如果是多型的(即被不同型別呼叫),那麼它將得到分別對應各型別組合操作的存根。

這意味著 JIT 在確定存根前要問許多問題。

4種型別檢查的決策樹
4種型別檢查的決策樹

在基線編譯器中,由於每一行程式碼都有各自對應的存根,每次程式碼執行時,JIT 要不斷檢查該行程式碼的操作型別。因此在每次迴圈時,JIT 都要詢問相同的問題。

需要 JIT 在每次迴圈時詢問型別的程式碼迴圈
需要 JIT 在每次迴圈時詢問型別的程式碼迴圈

如果 JIT 不需要重複這些檢查,程式碼執行速度會加快很多。這就是優化編譯器的工作之一了。

在優化編譯器中,整個函式會被一起編譯。所以型別檢查可以在迴圈開始前完成。

在迴圈開始前詢問問題的程式碼迴圈
在迴圈開始前詢問問題的程式碼迴圈

一些 JIT 編譯器做了進一步優化。例如,在 Firefox 中為僅包含整數的陣列設立了一個特殊分類。如果 arr 是在這個分類下的陣列,JIT 就不需要檢查 arr[i] 是否是整數了。這意味著 JIT 可以在進入迴圈前完成所有型別檢查。

總結

簡而言之,這就是 JIT。它通過監控程式碼執行確定高頻程式碼,並進行優化,加快了 JavaScript 的執行速度,因此令大多數 JavaScript 應用程式的效能提高了數倍。

即使有了這些改進,JavaScript 的效能仍是不可預測的。為了加速程式碼執行,JIT 在執行時增加了以下開銷:

  • 優化和去優化
  • 用於儲存監視器紀錄和應急回退時的恢復資訊的記憶體
  • 用於儲存函式的基線和優化版本的記憶體

這裡還有改進空間:除去以上的開銷,提高效能的可預測性。這是 WebAssembly 實現的工作之一。

下一篇文章中,我將對彙編做更多說明並解釋編譯器與它是如何工作的。

相關文章