WebAssembly 系列(三)編譯器如何生成彙編

做者:Lin Clark

編譯:鬍子大哈 javascript

翻譯原文:huziketang.com/blog/posts/…

英文原文:A crash course in just-in-time (JIT) compilersjava

轉載請註明出處,保留原文連接以及做者信息react


本文是關於 WebAssembly 系列的第二篇文章。若是你沒有讀先前文章的話,建議先讀這裏。若是對 WebAssembly 沒概念,建議先讀這裏(中文文章)編程

JavaScript 的啓動比較緩慢,可是經過 JIT 可使其變快,那麼 JIT 是如何起做用的呢?數組

JavaScript 在瀏覽器中是如何運行的?

若是是你一個開發者,當你決定在你的頁面中使用 JavaScript 的時候,有兩個要考慮的事情:目標和問題。瀏覽器

目標:告訴計算機你想作什麼。編程語言

問題:你和計算機說不一樣的語言,沒法溝通。函數

你說的是人類的語言,而計算機用的是機器語言。機器語言也是一種語言,只是 JavaScript 或者其餘高級編程語言機器能看得懂,而人類不用他們來交流罷了。它們是基於人類認知而設計出來的。post

因此呢,JavaScript 引擎的工做就是把人類的語言轉換成機器能看懂的語言。性能

這就像電影《降臨》中,人類和外星人的互相交流同樣。

在電影裏面,人類和外星人不只僅是語言不一樣,兩個羣體看待世界的方式都是不同的。其實人類和機器也是相似(後面我會詳細介紹)。

那麼翻譯是如何進行的呢?

在代碼的世界中,一般有兩種方式來翻譯機器語言:解釋器和編譯器。

若是是經過解釋器,翻譯是一行行地邊解釋邊執行

編譯器是把源代碼整個編譯成目標代碼,執行時再也不須要編譯器,直接在支持目標代碼的平臺上運行。

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

解釋器的利弊

解釋器啓動和執行的更快。你不須要等待整個編譯過程完成就能夠運行你的代碼。從第一行開始翻譯,就能夠依次繼續執行了。

正是由於這個緣由,解釋器看起來更加適合 JavaScript。對於一個 Web 開發人員來說,可以快速執行代碼並看到結果是很是重要的。

這就是爲何最開始的瀏覽器都是用 JavaScript 解釋器的緣由。

但是當你運行一樣的代碼一次以上的時候,解釋器的弊處就顯現出來了。好比你執行一個循環,那解釋器就不得不一次又一次的進行翻譯,這是一種效率低下的表現。

編譯器的利弊

編譯器的問題則剛好相反。

它須要花一些時間對整個源代碼進行編譯,而後生成目標文件才能在機器上執行。對於有循環的代碼執行的很快,由於它不須要重複的去翻譯每一次循環。

另一個不一樣是,編譯器能夠用更多的時間對代碼進行優化,以使的代碼執行的更快。而解釋器是在 runtime 時進行這一步驟的,這就決定了它不可能在翻譯的時候用不少時間進行優化。

Just-in-time 編譯器:綜合了二者的優勢

爲了解決解釋器的低效問題,後來的瀏覽器把編譯器也引入進來,造成混合模式。

不一樣的瀏覽器實現這一功能的方式不一樣,不過其基本思想是一致的。在 JavaScript 引擎中增長一個監視器(也叫分析器)。監視器監控着代碼的運行狀況,記錄代碼一共運行了多少次、如何運行的等信息。

起初,監視器監視着全部經過解釋器的代碼。

若是同一行代碼運行了幾回,這個代碼段就被標記成了 「warm」,若是運行了不少次,則被標記成 「hot」。

基線編譯器

若是一段代碼變成了 「warm」,那麼 JIT 就把它送到編譯器去編譯,而且把編譯結果存儲起來。

代碼段的每一行都會被編譯成一個「樁」(stub),同時給這個樁分配一個以「行號 + 變量類型」的索引。若是監視器監視到了執行一樣的代碼和一樣的變量類型,那麼就直接把這個已編譯的版本 push 出來給瀏覽器。

經過這樣的作法能夠加快執行速度,可是正如前面我所說的,編譯器還能夠找到更有效地執行代碼的方法,也就是作優化。

基線編譯器能夠作一部分這樣的優化(下面我會給出例子),不過基線編譯器優化的時間不能過久,由於會使得程序的執行在這裏 hold 住。

不過若是代碼確實很是 「hot」(也就是說幾乎全部的執行時間都耗費在這裏),那麼花點時間作優化也是值得的。

優化編譯器

若是一個代碼段變得 「very hot」,監視器會把它發送到優化編譯器中。生成一個更快速和高效的代碼版本出來,而且存儲之。

爲了生成一個更快速的代碼版本,優化編譯器必須作一些假設。例如,它會假設由同一個構造函數生成的實例都有相同的形狀——就是說全部的實例都有相同的屬性名,而且都以一樣的順序初始化,那麼就能夠針對這一模式進行優化。

整個優化器起做用的鏈條是這樣的,監視器從他所監視代碼的執行狀況作出本身的判斷,接下來把它所整理的信息傳遞給優化器進行優化。若是某個循環中先前每次迭代的對象都有相同的形狀,那麼就能夠認爲它之後迭代的對象的形狀都是相同的。但是對於 JavaScript 歷來就沒有保證這麼一說,前 99 個對象保持着形狀,可能第 100 個就少了某個屬性。

正是因爲這樣的狀況,因此編譯代碼須要在運行以前檢查其假設是否是合理的。若是合理,那麼優化的編譯代碼會運行,若是不合理,那麼 JIT 會認爲作了一個錯誤的假設,而且把優化代碼丟掉。

這時(發生優化代碼丟棄的狀況)執行過程將會回到解釋器或者基線編譯器,這一過程叫作去優化

一般優化編譯器會使得代碼變得更快,可是一些狀況也會引發一些意想不到的性能問題。若是你的代碼一直陷入優化<->去優化的怪圈,那麼程序執行將會變慢,還不如基線編譯器快。

大多數的瀏覽器都作了限制,當優化/去優化循環發生的時候會嘗試跳出這種循環。好比,若是 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] 會有一個相應的樁,而且把裏面的 += 操做當成整數加法。

可是,sumarr[i] 兩個數並不保證都是整數。由於在 JavaScript 中類型都是動態類型,在接下來的循環當中,arr[i] 頗有可能變成了 string 類型。整數加法和字符串鏈接是徹底不一樣的兩個操做,會被編譯成不一樣的機器碼。

JIT 處理這個問題的方法是編譯多基線樁。若是一個代碼段是單一形態的(即老是以同一類型被調用),則只生成一個樁。若是是多形態的(即調用的過程當中,類型不斷變化),則會爲操做所調用的每個類型組合生成一個樁。

這就是說 JIT 在選擇一個樁以前,會進行多分枝選擇,相似於決策樹,問本身不少問題纔會肯定最終選擇哪一個,見下圖:

正是由於在基線編譯器中每行代碼都有本身的樁,因此 JIT 在每行代碼被執行的時候都會檢查數據類型。在循環的每次迭代,JIT 也都會重複一次分枝選擇。

若是代碼在執行的過程當中,JIT 不是每次都重複檢查的話,那麼執行的還會更快一些,而這就是優化編譯器所須要作的工做之一了。

優化編譯器中,整個函數被統一編譯,這樣的話就能夠在循環開始執行以前進行類型檢查。

一些瀏覽器的 JIT 優化更加複雜。好比在 Firefox 中,給一些數組設定了特定的類型,好比裏面只包含整型。若是 arr 是這種數組類型,那麼 JIT 就不須要檢查 arr[i] 是否是整型了,這也意味着 JIT 能夠在進入循環以前進行全部的類型檢查。

總結

簡而言之 JIT 是什麼呢?它是使 JavaScript 運行更快的一種手段,經過監視代碼的運行狀態,把 hot 代碼(重複執行屢次的代碼)進行優化。經過這種方式,可使 JavaScript 應用的性能提高不少倍。

爲了使執行速度變快,JIT 會增長不少多餘的開銷,這些開銷包括:

  • 優化和去優化開銷
  • 監視器記錄信息對內存的開銷
  • 發生去優化狀況時恢復信息的記錄對內存的開銷
  • 對基線版本和優化後版本記錄的內存開銷

這裏還有很大的提高空間:即消除開銷。經過消除開銷使得性能上有進一步地提高,這也是 WebAssembly 所要作的事之一。


我最近正在寫一本《React.js 小書》,對 React.js 感興趣的童鞋,歡迎指點

相關文章
相關標籤/搜索