圖說 WebAssembly(二):JIT 編譯器

本文是圖說 WebAssembly 系列文章的第二篇,若是你還沒閱讀其它的,建議您從第一篇開始編程

JavaScript 的運行,一開始是很慢的,可是後面會變得愈來愈快,背後的功臣就是 JIT 。
可是 JIT 是如何工做的呢?segmentfault

JS 如何在瀏覽器中運行

做爲開發者,咱們給網頁寫 JavaScript 代碼是有明確目標的,固然也會伴隨着問題。數組

目標:告訴計算機要作什麼。
問題:咱們和計算機使用着不一樣語言。瀏覽器

咱們使用的是人類語言,而計算機則使用機器語言。
雖然你可能不一樣意把 JavaScript 或者其餘高級編程語言稱爲人類語言,但它們也確確實實是人類語言。
由於它們是按照人類認知被設計出來的,而不是機器認知。編程語言

因此,JavaScript 引擎的工做就是接收人類語言,而後輸出機器語言。
這就像電影《降臨》中所描述的同樣,人類試圖和外星人進行交流。函數

02-01-alien03.png

電影中,人類和外星人的交流並非經過逐個文字翻譯來實現的。這兩個羣體有不一樣的世界觀,這種差別也一樣適用於人類和機器。oop

那麼,人類和機器之間的「翻譯」又是怎麼實現的呢?性能

在編程領域,翻譯成機器語言有兩種通用的方式:解釋器和編譯器。優化

使用解釋器時,這種翻譯幾乎是實時且逐行進行的。spa

02-02-interp02.png

而對於編譯器,卻不是實時的,它須要提早翻譯並保存起來。

02-03-compile02.png

這兩種翻譯方式各有利弊。

解釋器優缺點

解釋器能夠快速啓動並運行代碼。
咱們不須要等待整個編譯步驟結束以後纔開始運行代碼。翻譯一行,運行一行。

基於此,解釋器看起來很是適合像 JavaScript 這樣的語言。由於對於一個互聯網開發者來講,快速開始並運行代碼是很是重要的。

這也是爲何瀏覽器從一開始就使用 JavaScript 解釋器的緣由。

可是,當須要屢次運行相同代碼時,解釋器的弊端就凸顯出來了。
好比,在一個循環中,解釋器得重複的翻譯相同的代碼。

編譯器優缺點

與解釋器相比,編譯器有着相反的優缺點。

編譯器會在開始時耗費比較多的時間,由於他須要經歷整個編譯過程。不過,一旦編譯好,循環中的代碼就能夠跑得更快,由於它再也不須要重複的翻譯相同代碼。

另外一個不一樣點是,編譯器有更多的時間來分析代碼,而後修改代碼,使它能跑得更快。這個修改過程稱爲優化
而解釋器就不一樣了,它是運行時進行代碼翻譯的,因此它無法在這個過程當中作優化。

集大成者:JIT 編譯器

爲了解決解釋器重複翻譯相同代碼低效行爲,瀏覽器開始把編譯器引入進來。

不一樣瀏覽器的作法有略微不一樣,可是基本作法是相同的。
它們爲 JavaScript 引擎新增了一個組件,稱爲監視器(Monitor,或者 Profiler)。
監視器的工做就是觀察代碼運行,而後記錄代碼的運行次數,以及它們使用的數據類型。

最開始時,監視器會觀察解釋器運行的全部代碼。

02-04-jit02.png

若是某一處的幾行代碼運行了好幾回,那麼該處的幾行代碼就被標記爲暖代碼(warm)。
若是運行了很是屢次,那麼就會被標記爲熱代碼(hot) 。

基準編譯器

當一個函數被標記爲暖代碼,JIT 就會把它發送給基準編譯器(Baseline Compiler)進行編譯,並把編譯結果保存下來。

02-05-jit06.png

函數中的每一行代碼都被編譯成一個存根(Stub)。這些存根在存儲時,使用代碼行號和變量類型做爲索引。
若是監視器發現相同的代碼運行使用的是相同變量類型,那麼它會取出已編譯好的代碼來運行。

能夠看出,這已經加快了運行速度。
不過,編譯器還能夠作得更好。它能夠花點時間來分析代碼,以便找出最高效的方式,也就是作優化。

基準編譯器也是可以作一些優化的(下文會舉例說明)。
可是它不能花費太多時間在優化上,由於咱們並不但願它長時間阻塞代碼運行。

優化編譯器

當有些代碼變成熱代碼,監視器就會把它發送給優化編譯器(Optimizing Compiler)。優化編譯器會把它編譯成另外一種更快版本的函數,而且保存起來。

02-06-jit09.png

爲了生成更快的代碼,優化編譯器必須做出一些前提假設。

好比,若是假設使用特定構造函數建立的對象都有相同的結構,即有相同的屬性名而且添加順序也是一致的,那麼優化編譯器就能夠基於此刪除一些代碼。

優化編譯器會基於監視器記錄的代碼運行信息來做出一些判斷。好比,若是在一個循環中,以前運行時某個變量一直是 true,那麼它就會假設它在將來仍然是 true

固然,在 JavaScript 中,實際上是沒有任何保證可言的。
可能以前的 99 個對象都有着相同的結構,可是到第 100 個對象時,它仍可能會缺乏某個屬性。

所以,編譯後的代碼在運行以前須要檢查原先的假設是否成立。
若是成立,那麼直接運行編譯後的代碼;若是不成立,那麼 JIT 會認爲它做出了錯誤假設,因而它會把優化的代碼廢棄掉。

02-07-jit11.png

而後,代碼的運行會返回去使用解釋器運行或者採用基準編譯器編譯的代碼。這個過程稱爲去優化(Deoptimization)。

一般來講,優化編譯器會使得代碼跑的更快。不過有時候,它也可能會致使意料以外的性能問題。
若是有一部分代碼一直在優化和去優化之間切換,那麼它其實比直接使用基準編譯器的編譯的代碼還更慢。

大多數瀏覽器已經增長了一些限制,來及時打破這種優化/去優化的循環過程。
好比說,當 JIT 嘗試了 10 次優化以後仍然發生了去優化,那麼它就再也不嘗試對其進行優化。

優化舉例:類型特定化

有不少種不一樣的優化方式,這裏咱們只舉例說明其中一種,來幫助理解整個優化過程。
在衆多優化方式中,類型特定化(Type Specialization)取得的優化是最明顯的。

JavaScript 採用的動態類型系統使得代碼在運行時須要作些額外的檢查工做。
好比,對於如下代碼:

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

其中的 += 操做看起來很是簡單,彷佛只須要進行一次操做就能完成計算。
可是,由於是動態類型,實際上進行的操做次數遠不足一次那麼簡單。

讓咱們假設 arr 是一個包含 100 個整數的數組。一旦該函數被標記爲暖代碼,基準編譯器就會爲該函數中的每個操做建立一個存根。因此 sum += arr[i] 也會對應一個存根,它會把 += 操做做爲整數加法。

然而,咱們並不能保證 sumarr[i] 都是整數。
由於 JavaScript 中的數據類型是動態的,因此在後續的循環中,arr[i] 可能就變成了字符串。
而整數加法和字符串鏈接是兩種徹底不一樣的操做,因此它們會被編譯爲徹底不一樣的機器代碼。

對於這種狀況,JIT 的處理方式是編譯成多種不一樣的基準存根。
若是每次調用代碼都使用相同的數據類型,那麼只會生成一種存根;若是每次調用使用不一樣的數據類型,那麼會生成每種類型組合起來的存根。

也就意味着,JIT 在選擇一個存根以前必須先作好多判斷。

02-08-decision_tree01.png

由於每一行代碼在基準編譯器中都會有它本身的存根集合,因此每行代碼運行時 JIT 須要一直進行類型判斷。所以,在該循環中的每一次遍歷,它都要進行相同的類型判斷過程。

02-09-jit_loop02.png

若是 JIT 不須要每次都重複這些類型判斷,那麼代碼跑起來就會更快。而這正是優化編譯器所作的優化之一。

在優化編譯器中,整個函數是一塊兒編譯的。因此能夠把類型判斷移到循環以前。

02-10-jit_loop02.png

一些 JIT 對此作了更深的優化。好比,在 Firefox 中,咱們把只包含整數的數組劃分爲特殊的數組分類。若是 arr 是這種數組,那麼 JIT 就不須要檢查 arr[i] 是不是一個整數了。這樣的話,JIT 能夠在進入循環以前就作完全部的類型判斷。

結束

以上就是對 JIT 的簡短介紹。
經過監視代碼運行,編譯熱代碼等方式,JIT 使得 JavaScript 代碼跑的更快。這爲大多數 JavaScript 應用帶來了許多性能改進。

儘管作了這些優化,可是 JavaScript 的性能可能仍然沒法預測。
由於作這些優化的同時,咱們也給運行時增長了額外的開銷,包括:

  • 優化和去優化過程
  • 監視器記錄和恢復信息佔用的內存
  • 用於保存基準、優化後函數的內存

不過,對於這些仍然有改進的空間,咱們能夠消除這些額外開銷,使得性能提高更具可預測性。
而這就是 WebAssembly 所作的一件事情!

下一篇文章中,咱們將更詳細介紹 WebAssembly ,以及它是如跟編譯器一塊兒工做的。

相關文章
相關標籤/搜索