天天看點

JIT 編譯器快速入門

<b>本文講的是JIT 編譯器快速入門,</b>

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

作為一名開發者,當你向網頁中添加 JavaScript 代碼的時候,你有一個目标和一個問題。

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

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

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

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

一個人用源代碼示意,外星人以二進制回應

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

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

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

解釋器會在程式運作時對代碼進行逐行轉義。

一個人正在白闆前将代碼翻譯成二進制

相反的是,編譯器會提前将代碼轉義并儲存下來,而不是在運作時對代碼進行轉義。

一個人拿着一頁翻譯後的二進制代碼

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

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

是以,解釋器看起來自然很适用于 JavaScript 這類語言。對于 Web 開發者來說,能夠快速運作代碼相當重要。

這就是各浏覽器在初期使用 JavaScript 解釋器的原因。

但是當你重複運作同樣的代碼時,解釋器的劣勢就顯現出來了。舉個例子,如果在循環中,你就不得不重複對循環體進行轉化。

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

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

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

解釋器在程式運作時工作,是以它無法在轉義過程中花費大量時間來确定這些優化。

為了解決解釋器在循環時重複編譯導緻的低效問題,浏覽器開始将編譯器混合進來。

不同浏覽器的實作方式稍有不同,但基本思路是一緻的。它們向 JavaScript 引擎添加了一個新的部件,我們稱之為螢幕(又名分析器)。螢幕會在代碼運作時監視并記錄下代碼的運作次數和使用到的類型。

起初,螢幕隻是通過解釋器執行所有操作。

螢幕監控代碼運作并發出解釋代碼的信号

如果一段代碼運作了幾次,這段代碼被稱為 warm code;當這段代碼運作了很多次時,它就會被稱為 hot code。

當一個函數運作了數次時,JIT 會将該函數發送給編譯器編譯,然後把編譯結果儲存下來。

螢幕發現一個函數運作了數次,示意應該将這段函數發送給基線編譯器建立一個存根

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

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

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

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

當一段代碼運作的頻率非常高時,螢幕會把它發送給優化編譯器。然後得到另一個運作速度更快的函數版本并儲存下來。

螢幕發現一段代碼運作了更多遍,示意這段代碼應該被全面優化

為了得到運作速度更快的代碼版本,優化編譯器會做一些假設。

舉例來說,如果它可以假設由特定構造函數建立的所有對象結構相同,即所有對象的屬性名相同,并且這些屬性的添加順序相同,然後它就可以基于這個進行優化。

優化編譯器會依據螢幕監測代碼運作時收集到的資訊做出判斷。如果在之前通過的循環中有一個值總是 true,它便假定這個值在後續的循環中也是 true。

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

是以編譯後的代碼在運作前需要檢查假設是否有效。如果有效,編譯後的代碼即運作。但如果無效,JIT 就認為它做了錯誤的假設并銷毀對應的優化後代碼。

螢幕發現類型與期望不比對,示意回到解釋器。優化器将得到的優化代碼銷毀

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

通常優化編譯器會加快代碼運作速度,但有時它們也會導緻意外的性能問題。如果你的代碼被不斷的優化和去優化,運作速度會比基線編譯版本更慢。

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

優化的類型有很多,但我隻示範其中一種以便你了解優化是如何發生的。優化編譯器最大的成功之一來自于類型專門化。

JavaScript 使用的動态類型系統在運作時需要多做一些額外的工作。例如下面這段代碼:

執行循環中的 <code>+=</code> 一步似乎很簡單。看起來你可以一步就得到計算結果,但由于 JavaScript 的動态類型,處理它所需要的步驟比你想象的多。

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

然而我們并不能保證 <code>sum</code> 和 <code>arr[i]</code> 一定是整數。因為在 JavaScript 中資料類型是動态的,有可能在下一次循環中的 <code>arr[i]</code> 是一個字元串。整數加法和字元串拼接是兩個完全不同的操作,是以也會編譯成非常不同的機器碼。

JIT 處理這種情況的方法是編譯多個基線存根。一段代碼如果是單态的(即總被同一種類型調用),将得到一個存根。如果是多态的(即被不同類型調用),那麼它将得到分别對應各類型組合操作的存根。

這意味着 JIT 在确定存根前要問許多問題。

4種類型檢查的決策樹

在基線編譯器中,由于每一行代碼都有各自對應的存根,每次代碼運作時,JIT 要不斷檢查該行代碼的操作類型。是以在每次循環時,JIT 都要詢問相同的問題。

需要 JIT 在每次循環時詢問類型的代碼循環

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

在優化編譯器中,整個函數會被一起編譯。是以類型檢查可以在循環開始前完成。

在循環開始前詢問問題的代碼循環

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

簡而言之,這就是 JIT。它通過監控代碼運作确定高頻代碼,并進行優化,加快了 JavaScript 的運作速度,是以令大多數 JavaScript 應用程式的性能提高了數倍。

即使有了這些改進,JavaScript 的性能仍是不可預測的。為了加速代碼運作,JIT 在運作時增加了以下開銷:

優化和去優化

用于存儲螢幕紀錄和應急回退時的恢複資訊的記憶體

用于存儲函數的基線和優化版本的記憶體

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

<b></b>

<b>原文釋出時間為:2017年3月14日</b>

<b>本文來自雲栖社群合作夥伴掘金,了解相關資訊可以關注掘金網站。</b>

繼續閱讀