V8 之旅:優化編譯器

發表於2015-07-22

在之前的兩篇文章中,我們討論了V8的Full Compiler物件的內部表示。在幾年前,FC生成的原生程式碼相對於JavaScript來說已經不錯了,但人們對效能的要求與日俱增,其速度標杆也越來越高,因此衍生出了Crankshaft。

本文來自Jay Conrod的A tour of V8: Crankshaft, the optimizing compiler,其中的術語、程式碼請以原文為準。

Crankshaft是V8的優化編譯器。回憶一下,V8有兩個編譯器,另一個編譯器FC負責儘快生成未優化的程式碼。對於只執行幾次的程式碼,FC生成的程式碼還是比較理想的。當FC產生的程式碼執行過一段時間之後,V8會挑選出“熱門”的函式,重新用Crankshaft編譯。這大大提升了效能。

熱身完畢

如果只看指令碼,很難說哪個函式最應當得到優化。V8使用一個執行時效能分析器在指令碼執行的時候識別熱門的函式。

Crankshaft剛開始部署時,V8選擇在另一執行緒跑棧取樣分析器(stack sampling profiler)。幾乎每隔一段時間(桌面版本為1ms,移動版本為5ms)那個執行緒就被喚醒一次,然後向主執行緒傳送SIGPROF訊號。主執行緒的訊號處理程式碼會重置棧頂的高度(一般是一個標誌棧結束的地址)。每當一個函式被呼叫以及每輪迴圈的時候,經過JIT的程式碼會檢查是否達到棧頂,如果棧指標超過了棧頂,就會呼叫執行時來報錯。這給了效能分析器一個打斷指令碼執行的時機。V8執行時會在檢查出棧溢位是因為訊號SIGPROF時呼叫效能分析器,於是效能分析器就可以在這時看到棧頂的幾幀,然後標註這些函式進而優化。

這種效能分析器有幾個短板。由於取樣是幾近隨機的,效能因素並不主導取樣。儘管分析器在統計上傾向於挑選出最熱門的函式,但其可能在頁面每次過載時得出不同順序或不同次數的結果。想象一下當時的情景,效能測試的時候得出的是差異很大的結果,V8測試集當中的某些測試甚至能在多次執行時差距達50%。同時這種方案會因其打斷程式碼執行的機制,在整體上對不含迴圈的大函式有失偏頗。

V8如今用基於計數的效能分析器(counter-based profiler)。每個經過FC的函式都包含一個計數器,當函式返回或完成一輪迴圈的時候,就會減少計數的值。而減多少則依據函式或迴圈的大小,因此這對於大函式和迴圈來說更加公平。分析器在計數減到0的時候呼叫,然後和棧取樣分析器類似(實際上更加出色),但更側重效能地選出熱門函式。另外這樣對於已優化的程式碼來說沒有任何影響,因為只有未優化的程式碼才會有計數器。

一旦一個函式被分析器標記為需要優化,指向其程式碼的指標就會被改寫指向為一個V8內建的函式——LazyRecompile,來呼叫編譯器。這樣函式就會在下次呼叫時得到優化。

剖析Crankshaft

Crankshaft經過以下幾個階段生成程式碼:

  • 語法分析:這一階段負責將原始碼翻譯為AST。Crankshaft與FC共享同一個語法分析器,但出於空間佔用考慮,V8並不保留任何編譯器所得到的AST(以及其他中間產物)。而且AST也不常用,生成也很容易。
  • 作用域分析:在這一階段,V8將確定變數是如何被使用的,將其與各自的定義連結。區域性變數、閉包變數、全域性變數的對待方式會各不相同。這個階段也與FC共享。某些程式碼的寫法(比如用eval動態引入變數)會使一個函式失去優化或內聯的資格。
  • 圖生成:Crankshaft使用AST、作用域資訊以及FC程式碼反饋而來的型別資訊,來構建Hydrogen控制流程圖。內聯也發生在這一階段。Hydrogen是Crankshaft中一個高層次、架構無關的中間程式碼。
  • 優化:絕大多數優化都基於Hydrogen控制流程圖發生在這一階段。這是Crankshaft唯一能夠與JS執行緒並行的時候。雖然本文撰寫時還不是並行的。
  • 低階化:優化結束之後,Hydrogen外會生成一個Lithium圖。Lithium圖是一個低階且架構相關的中間程式碼。暫存器的分配就是在這一階段施行的。
  • 程式碼生成:在這個最後階段中,Crankshaft按照Lithium圖中的每個指令傳送原生指令。後設資料,比如重定位資訊以及去優化資料,也是在這一階段生成。完成之後,經過JIT的程式碼會放在一個Code物件中,然後繼續指令碼的執行。

整個結構對於優化編譯器來說非常典型,然而某些方面可能會讓你驚訝。首先,Crankshaft並沒有一個真正的低階表達形式。Hydrogen和Lithium的指令基本上和JS中的操作相對應。在某些情況下,十來個原生指令才對應一個Lithium指令。這可能會導致生成出來的程式碼中有一定的冗餘。第二,Crankshaft一種指令排程都沒有。對於x86處理器來說不算大問題,因其最終是亂序執行;但對於一些相對簡單的RISC指令集架構,比如ARM,會造成一些麻煩。

這些缺點大多是因為Crankshaft在效能影響上的重要地位。JS程式碼在Crankshaft執行時是中斷的,這就意味著Crankshaft必須儘快完成任務,而任何附加的階段和優化都可能得不償失。V8的開發者們正在為Crankshaft的並行上努力,但這一功能目前還沒有開啟。V8的堆並不支援多執行緒訪問,而如果不訪問堆,則只有優化階段能夠並行。然而使堆變得執行緒安全是一項複雜的任務,可能會引入額外的同步負擔。

Hydrogen與Lithium

V8的高階中間程式碼叫做Hydrogen,而低階中間程式碼叫Lithium。如果你曾經用過LLVM,Hydrogen的結構看起來會有些熟悉。函式被表達為一個由一組區塊構成的流程圖,其中的每個區塊都包含一系列靜態單賦值形式(SSA)的指令。每條指令則包含一組操作符和一組操作符呼叫,因此你可以將其想象為一個疊在流程圖之上的資料流圖。每個Hydrogen指令表示一個較為高階的操作,比如算術運算、屬性的存/取、函式呼叫或者型別檢查。

大多數優化過程發生在Hydrogen身上或構造Hydrogen的時候。這是Crankshaft所執行的優化:

  • 動態型別反饋:在圖生成的同時,大多數操作會特化為對一個型別的操作。這些型別在FC程式碼的內聯快取中得到。比如,如果IC實現的是一個讀取操作,而這個讀取只作用於一種物件的某個屬性,特化的Hydrogen程式碼會將被優化為只處理這一種物件的程式碼。為此,必要時會加入型別檢查。
  • 內聯:發生在圖的生成階段。內聯的啟發規則非常簡單:基本上如果一個函式在呼叫時已知且內聯它是安全的,那麼就內聯它。大函式(原始碼中大於600個包括空格在內的字元,或超過196個AST節點)則不會內聯。最多隻有196個語法節點可在一個函式中內聯。
  • 形式推斷:Hydrogen支援三種暫存器中的值:標記值、整型值和雙精度浮點。所謂標記值,就是指這個值是封箱的。字串和物件總是標記值,但數字則不一定。原生整型和雙精度浮點更有效率,但所有的值都必須在存到記憶體中或傳遞給另一個函式前標記。這一環決定了每個值應有的表達形式。
  • 靜態型別推斷:這一步Crankshaft嘗試確定函式中各種值的型別。由於一次只能針對一個函式而且大多數操作(比如函式呼叫、屬性讀取)產生的型別無法推斷,這步效率很低。不過有些型別檢查會因為該值有精確的型別資訊而省去。
  • UInt32分析:V8使用31bits來表示小整數。其最低位保留給垃圾回收器用以判定它是指標還是數字(0表示數字,1表示指標)。Crankshaft可以使用全部32-bit來儲存不經過記憶體的區域性變數和臨時值,而當超出這一區間時,則需要特殊處理。語義上,JavaScript將所有數字都視為64位雙精度浮點,因此允許用整數來表達原本是錯誤的。但這一步將某些操作歸納為無符號的,於是微小的溢位並不會在這些特定情況下造成麻煩。這對於諸如密碼學、壓縮和圖形處理相關的程式來說非常有用。
  • 標準化:這步是用來進行簡化的。它去掉了不必要的操作,並進行一些簡化。
  • 值編號(GVN):這是去除冗餘的標準步驟。依次處理每個指令,當一個指令處理之後,會生成一個基於其操作、輸入以及任何相關資料的雜湊值,插入到一個雜湊表中。後續如果遇到同樣雜湊值的指令,則GVN會刪除後續的那個。但每當遇到對該指令存在副作用的指令,則從雜湊表中清除原先存入的這個指令。比如,對於兩個完全一樣的讀取操作來說,如果中間還有一個儲存操作,則這兩個讀取操作並不能合併。
    移出迴圈無關程式碼(LICM):這個和GVN同時執行。迴圈中並不依賴迴圈中其他程式碼的指令,將被提到迴圈之前。對迴圈無關值的型別檢查也會被提到迴圈之前,但這可能會導致某些場景下並不會發生的型別檢查也被執行,因此編譯器會在這時趨於保守。
  • 範圍分析:這步會確定每個整數操作的上限和下限。這樣就有可能去掉一些溢位檢查。在實踐中,這個並不太精確。
  • 去除冗餘的陣列範圍檢查:去掉某些已在陣列元素訪問中執行過的多餘陣列範圍檢查。
  • 後推陣列下標計算:這一步會反轉LICM對一些非常簡單的表示式(如陣列下標增加或減去一個常數)的效果。所有V8支援的架構都有指令來完成定址時對下標的簡單加減,因此提前這些程式碼通常也沒什麼大的好處。
  • 去除無效程式碼:這是一種清理工作。它會移除掉Hydrogen指令中無效或者沒有副作用的部分。這些指令往往是其他優化所產生的副產品,而程式設計師自己所寫的無效程式碼則不包含在內:V8可能會在函式執行的過程當中,在優化程式碼和未優化程式碼之間切換,而如果優化後的程式碼缺失了某個未優化程式碼所需要的值,則可能會造成崩潰。(譯註:也就是說,在Crankshaft看來可能無效的程式碼,很可能只是整個指令碼的一部分,而整個指令碼中的其它未優化部分,實際是需要那部分看似無效的程式碼的。

Lithium是V8的低階、機器相關的中間程式碼。實際上它並不是特別的低階:每個Hydrogen指令至多被低階化為一個Lithium指令。有些Hydrogen指令,例如HConstantHParameter原封不動變成了Lithium指令。其他一些Hydrogen指令會被低階化為某些由運算元銜接起來的指令(而在Hydrogen中它們本是直接相連的)。這裡的運算元可能是常數、暫存器,或者棧槽。暫存器分配器將決定每個運算元的型別和其存放位置。大多數指令只支援暫存器運算元,因此暫存器分配器需要在這些運算元中間增加存取的指令。

原生程式碼將從Lithium指令得出。簡單的Lithium指令可能只對應一條原生指令,而某些複雜的Lithium指令則會對應10-20條原生指令(ARM如此,其他架構可能有差異)。

即使是在只處理常規使用的優化程式碼當中,你也會因JavaScript指令碼最終產生的複雜程式碼而咋舌。舉例來說,以下是為一個JS物件增加一個屬性的程式碼:

 

棧上替換

我們講效能分析器時說過,經過分析器標記的函式,編譯器會在下一次呼叫時對其進行優化編譯,而當編譯工作結束後,執行時即會載入新的優化程式碼來執行。對於大多數函式來說,這種機制已經足夠好了,但對於包含熱門迴圈,且只執行一次的函式來說,這個機制就有瑕疵了。

這類函式仍然需要優化,因此一種略複雜的機制應運而生。如果效能分析器被觸發時,一個函式已被標記為需要優化但還沒有再次被呼叫,則分析器會嘗試棧上替換。分析器會直接呼叫Crankshaft,立即生成出優化程式碼;當Crankshaft執行完畢時,V8會依據原先包含該函式的棧幀中的其他程式碼,構造一個新的棧幀來存放優化後的程式碼。這時將舊的棧幀彈出,壓入新的棧幀,恢復指令碼的執行。

這一過程需要兩個編譯器的支援。FC需要產生包含該棧幀中其他值的位置資訊,而Crankshaft需要生成這些值即將存放的新位置的資訊。同時,Crankshaft還需要生成額外的“接入層”,來將這些值從棧幀讀取到正確的暫存器當中。

去優化

上面提到過,Crankshaft生成的程式碼只是特化針對於FC所遇到的型別,因此Crankshaft無法處理所有可能的值。我們需要一種機制來在遇到未知型別和算術運算溢位時優雅降級,換用FC的程式碼。這種機制就叫做去優化。通常去優化就是進行棧上替換的逆操作。然而這裡面有個小問題:Crankshaft支援內聯,因此型別問題有可能會在內聯程式碼之內發生。這時去優化過程將不得不對多個棧幀進行操作。

為了使去優化器的工作更加容易,Crankshaft會生成去優化的輸入資料,每個去優化可能發生的地方,都會有相應的命令關聯。去優化器將通過這些命令,來將暫存器、棧槽等從優化程式碼中的值,轉換為未優化程式碼中的值。每條命令都包含有與棧相關的操作,比如“從暫存器r6中獲取值,將其封箱為數字,然後壓入下一棧槽”。去優化器的任務就是尋找正確的命令,將其執行,然後彈出優化的棧幀,壓入相應未優化的棧幀。

總結

Crankshaft是V8的祕密武器。通過執行時的效能分析器,V8能夠檢測出最需要優化的函式。由於V8的去優化隨時可能需要將程式碼降為未優化程式碼,Crankshaft可以在一定程度上具有推斷能力,只優化特定情況下的程式碼。

在將來,Crankshaft很可能會並行。由於可以騰出更多的時間優化更多的程式碼,這能夠讓V8的效能更高。

相關文章