WebAssembly 系列(第二部分) - 即時編譯器(JIT)速成課
ofollow,noindex">原文連結
這是 WebAssembly 系列的第二篇,如果你沒讀過第一篇,我建議你讀下。
JavaScript 創立之初,執行速度是很慢的,但是現在執行速度很快,得益於一種叫做 JIT 的技術,那麼你瞭解 JIT 是如何工作的嗎?
JavaScript 是如何在瀏覽器中執行的?
當你作為一個開發者,向頁面中新增 JavaScript 指令碼,你可能有一個目標和問題。
目標:你想要告訴瀏覽器做些什麼
問題:你和電腦所說語言不同
你說人類的語言,而電腦說機器語言。即使你不將 JavaScript 和其他的高階語言看作人類語言,但它們是。它們被設計用來人類識別,而不是機器識別。
因此 JavaScript 引擎的任務就是將人類語言解釋成機器能夠理解的東西。
我將這看作是在電影《降臨》裡面,人類和外星人想要相互溝通。

在那部電影中,人類和外星人並不是逐字逐句的翻譯。這兩個群體有不同的理解世界的方式。這也適用於人類和機器之間。
那麼翻譯是如何發生的呢?
在程式設計中,通常有兩種方式將人類語言轉換為機器語言。你可以使用一個直譯器或者編譯器。
使用直譯器,這種轉換是在執行中逐行發生的。

另一方面編譯器不是解釋型的,它是提前工作的,翻譯,然後寫下來。

這兩種翻譯方式都有贊成者和反對者。
直譯器的贊成和反對
直譯器能夠快速啟動和執行。在程式碼執行之前,不需要完成整個編譯步驟。僅僅是翻譯第一行並且執行。
因此,翻譯似乎是適合 JavaScript 的。對於一個 Web 開發者能夠快速執行程式碼非常重要。
這就是為什麼瀏覽器在開始使用 JavaScript 直譯器的原因。
但是當你多次執行通用的程式碼時,直譯器的缺點就顯現出來了。例如,如果你在一個迴圈裡面。你需要一次又一次地做相同的翻譯。
編譯器的贊成和反對
編譯器有著相反的開銷。
它需要一點兒時間啟動,因為它必須在執行之前完成所有的編譯工作。但是在迴圈中的程式碼執行的更快了,因為它不需要在每一次迴圈都重複翻譯。
另外一個不同是,編譯器有更多的時間去看程式碼,並且對程式碼做一些編輯,讓它執行更快。這些編輯被稱為優化。
直譯器在執行時做這些工作,因此它在轉換階段不會花費更多時間去做這些優化。
即時編譯器:兩全其美
作為一種擺脫低效率的方式 - 直譯器在遇到迴圈時,不得不重複翻譯 - 瀏覽器開始將其和編譯器混合起來。
不同的瀏覽器的實現有細微的差別,但是基本的理念都是一樣。他們為 JavaScript 添加了新的部分,被稱為監視器(也被稱為分析器)。當代碼執行的時候,監視器會監視程式碼。會記錄呼叫了多少次,用的什麼型別的資料。
起初,監視器通過直譯器執行所有的程式碼。

如果同樣的程式碼運行了幾次,那麼這段程式碼就被稱為 “warm”。如果執行的次數更多,就被稱為 “hot”。
基線編譯器
當一個函式開始變成 “warm”,JIT 會將它送去編譯。然後它會儲存這次編譯。

函式的每行程式碼被翻譯為一個 “存根”(stub)。這些存根被行號和變數型別編入索引(待會兒我會解釋為什麼這樣是重要的)。如果監視器發現帶有同樣的變數型別的同樣的程式碼再次被執行,它會退到編譯的版本。
這有助於加速。但是像我說的,編譯器不止做這些事情。它會花些時間辨識出做事情更高效的方式... 會做些優化。
基線編譯器將會做一些優化(我下面給一個例子),它不會花費太多時間,因為它不會耽誤執行的進行。
然而,如果程式碼實際上被標記為 “hot” - 如果被執行很多次 - 值得花更多的時間做更多的優化。
優化編譯器
如果一段程式碼執行的次數非常多,監視器將會把它送到優化編譯器。 這將會建立另一個,更快的,將會被儲存的函式的版本。

為了得到程式碼快速執行的版本,優化編譯器不得不做一些假設。
例如,如果它能夠假設所有建立的物件都被同樣 shape (也就是有相同的屬性名,並且屬性新增的順序也是一樣的)的建構函式建立的,那麼它就能夠減少不必要的工作。
優化編譯器使用監視器通過監視程式碼收集到的資訊做的這些假設。如果之前的迴圈中所做的假設是正確的,那麼它將繼續按照這樣的假設。
但是在 JavaScript 中,沒有絕對的保證。前 99 個物件都有同樣的 shape,但第 100 個可能會少個屬性。
因此編譯過的程式碼在執行之前需要檢查一遍,看看假設是否是正確的。如果是正確的,程式碼接著執行。如果是錯誤的,JIT 會認為它做了錯的假設並且丟棄掉優化過的程式碼。

執行會回退到直譯器或者基線編譯器版本,這個過程被稱為去優化。
通常情況下,優化編譯器讓程式碼執行更快了,但有些時候會導致不可預見的效能問題。如果你的程式碼優化了,然後再去優化,結果就是執行的速度還不如基線編譯器的版本的程式碼。
大多數瀏覽器在他們發生時,對於這種打破優化/去優化週期添加了限制。如果瀏覽器做了 10 次優化,但都被丟棄了,它會停止嘗試。
一個示例:型別專門化(type specialization)
這裡有不同種類的優化,但我想看看 一種型別,讓你對優化是如何發生的又一個認識。
在優化編譯器,最大的優化之一是型別專門化。
JavaScript 使用的動態的型別系統,在執行期需要一點兒額外的工作。例如,考慮以下程式碼。
function arraySum(arr) { var sum = 0; for (var i = 0; i < arr.length; i++) { sum += arr[i]; } }
迴圈中 += 這一步看著很簡單,看起來一步就能完成。但是因為是動態型別,它會有比你預想的更多的步驟。
我們假設 arr 是一個包含 100 個整數的陣列,一旦程式碼被標記為 “warm”,基線編譯器會為函式中的每個操作步驟建立一個存根。因此這裡會有一個 sum += arr[i] 的存根,這將作為整數的加法來執行 += 操作。
但是 sum 和 arr[i] 不能保證就是整數,因為在 JavaScript 中型別是動態的;有可能在後面的迴圈中 arr[i] 是一個字串呢。整數型的加法和字串的連線是兩種不同的操作,因此也會被編譯為不同的機器程式碼。
對於這種情況,JIT 是通過編譯多行基線存根處理的。如果一段程式碼是單一態的(帶有相同型別引數的呼叫),它會得到一個存根。如果是多型的(每次呼叫引數型別都不一樣),通過該操作它會得到一個每個型別組合的存根。
這意味著 JIT 在選擇一個存根之前不得不問更多的問題。

因為在基線編譯器中,每行程式碼都有自己的存根集合,JIT 在每行程式碼執行的時候需要檢查型別。因此在迴圈中,每次迴圈都需要問相同的問題。

如果 JIT 不需要那些重複的檢查,程式碼將會執行快很多。這是優化編譯器做的事情之一。
在優化編譯器中,整個函式是一起被編譯的。型別檢查被移到了迴圈之前。

有些 JIT 會進一步優化。例如在 Firefox 中,對於整型的陣列有一個特殊的分類。如果 arr 是這些陣列之一,JIT 就不需要檢查 arr[i] 是否是一個整數。這意味著在開始迴圈之前,就已經做好了型別檢查。
結論
簡而言之這就是 JIT。通過監視程式碼讓程式碼執行更快,並且把標記為 “hot” 的程式碼路徑送去優化。這導致了大多數的 JavaScript 應用的效能改進。
即使有這些改進,JavaScript 的效能仍然是不可預知的。為了讓其執行更快,JIT 在執行時添加了一些開銷,包含:
- 優化和去優化
- 監視器儲存資訊所需的記憶體和去優化時的恢復資訊
- 記憶體需要儲存函式的基線和優化版本
這裡有改進的餘地:這些開銷都可以去掉,讓效能更可預測。並且那也是 WebAssembly 要做的事情之一。
在下一篇文章中,我將會解釋更多關於彙編的知識並且編譯器是如何處理它的。