Maglev - V8 最快的優化 JIT
在 Chrome M117 中,我們引入了一個新的優化編譯器:Maglev。Maglev 位於現有的 Sparkplug 和 TurboFan 編譯器之間,扮演快速生成足夠好的代碼的快速優化編譯器角色。
直到 2021 年,V8 主要有兩個執行層級:Ignition(解釋器);以及 TurboFan,V8 的優化編譯器,專注於峰值效能。所有 JavaScript 代碼首先編譯為 Ignition bytecode,並通過解釋來執行。在執行期間,V8 追蹤程式的行為,包括追蹤物件形狀和類型。執行時的元數據及 bytecode 都會被輸入到優化編譯器中,以生成高效能(通常是投機性的)機器代碼,可以顯著快於解釋器執行。
這些改進在像 JetStream 一類的基準上清晰可見。JetStream 是一組測量啟動、延遲和峰值效能的傳統純 JavaScript 基準測試。TurboFan 使 V8 運行這套程式快 4.35 倍!JetStream 相較於以往的基準(如 退役的 Octane 基準測試)對穩態效能的強調減少,但由於許多測試項目的簡單性,優化代碼仍是大部分時間的消耗所在。
Speedometer 是與 JetStream 不同類型的基準套件。它旨在通過測量模擬用戶交互所需時間來評估網頁應用的響應速度。程序不是較小的靜態獨立 JavaScript 應用,而是由完整網頁組成,大多數是使用流行框架構建而成。像大多數網頁加載過程一樣,Speedometer 的測試項目花費更少的時間運行緊湊的 JavaScript 循環,更多是執行與瀏覽器其餘部分交互的大量代碼。
TurboFan 在 Speedometer 上仍有很大影響:執行速度快了 1.5 倍以上!但影響明顯比在 JetStream 上更小。部分原因是完整頁面花費在純 JavaScript 上的時間更少,部分原因則是基準測試中有許多函數的執行熱度不夠,因此無法被 TurboFan 優化。
::: note 本文中所有的基準分數均使用 Chrome 117.0.5897.3 於一台 13” M2 Macbook Air 測量。 :::
由於 Ignition 和 TurboFan 的執行速度和編譯時間之間的差距過大,2021 年我們推出了一個新的基準 JIT,稱為 Sparkplug。它旨在幾乎瞬間將 bytecode 編譯為等效的機器代碼。
在 JetStream 上,相較於 Ignition,Sparkplug 提升了不少效能(+45%)。即便有 TurboFan 的存在,我們依然能看到一個可靠的效能提升(+8%)。在 Speedometer 基準測試中,相較於 Ignition,我們看到速度提升了 41%,接近 TurboFan 效能,同時也相較於 Ignition + TurboFan 提升了 22%的效能!由於 Sparkplug 的速度非常快,我們可以很容易廣泛部署,並獲得一致的加速。如果代碼不僅依賴於容易優化的長時間運行的緊湊 JavaScript 循環的話,它就是一個很好的補充。
然而 Sparkplug 的簡單性施加了一個相對較低的速度提升上限。這通過 Ignition + Sparkplug 和 Ignition + TurboFan 之間的巨大差距可以清楚地看出。
這就是 Maglev 的用武之地。它是一個新的優化 JIT,可以生成比 Sparkplug 代碼更快的代碼,但生成時間卻比 TurboFan 快得多。
Maglev:基於簡單 SSA 的 JIT 編譯器
當我們開始這個專案時,我們看到了兩條填補 Sparkplug 和 TurboFan 之間差距的路徑:要麼嘗試使用 Sparkplug 採用的單通道方法生成更好的代碼,要麼構建一個具有中間表示(IR)的 JIT(即時編譯器)。由於我們認為在編譯過程中完全沒有 IR 可能會嚴重限制編譯器,所以我們選擇了一種相對傳統的基於靜態單一指派(SSA)的方法,使用控制流程圖(CFG)來代替 TurboFan 較靈活但對快取不友好的節點海洋(sea-of-nodes)表示法。
編譯器本身被設計為快速且易於操作。它具有一組最小化的處理階段,以及一個簡單的、單一的 IR,這個 IR 編碼了專門的 JavaScript 語義。
前置處理
首先,Maglev 對位元組碼進行一次前置處理以查找分支目標(包括迴圈)以及對迴圈中變數的賦值。此處理階段還會收集活性信息,編碼哪些變數中的值在哪些表達式中仍然需要。這些信息可以減少後續編譯器需要跟蹤的狀態數量。
SSA
Maglev 對框架狀態進行抽象解釋,創建表示表達式評估結果的 SSA 節點。通過將這些 SSA 節點儲存在對應的抽象解釋器寄存器中來模擬變數賦值。對於分支和開關情況,所有路徑都會被評估。
當多條路徑合併時,抽象解釋器寄存器中的值會通過插入所謂的 Phi 節點進行合併:這些值節點根據在執行時採取的路徑選擇相應的值。
在變數在迴圈體內被賦值的情況下,迴圈可以“回溯”合併變數值,數據從迴圈結束流回到迴圈頭部。這時前置處理的數據變得有用:由於我們已經知道哪些變數在迴圈內被賦值,因此我們可以在開始處理迴圈體之前預先創建迴圈 phi 節點。在迴圈結束時,我們可以用正確的 SSA 節點填充 phi 輸入。這樣,SSA 圖生成可以單向向前處理,而不需要“修復”迴圈變數,同時還能最小化需要分配的 Phi 節點數量。
已知節點信息
為了達到最快的速度,Maglev 盡可能同時完成更多工作。與其構建一個通用的 JavaScript 圖並在後續的優化階段中降低該圖(這是一種理論上乾淨但計算昂貴的方法),Maglev 儘可能在圖建構時立即完成更多工作。
在圖建構過程中,Maglev 會查看在未優化執行期間收集的運行時回饋元數據,並為觀察到的類型生成專用的 SSA 節點。例如,如果 Maglev 見到 o.x
並從運行時回饋中得知 o
一直具有一個特定形狀,那麼它會生成一個 SSA 節點來檢查在運行時 o
是否仍然具有期望的形狀,然後是一個便宜的 LoadField
節點,該節點通過偏移進行簡單的訪問。
此外,Maglev 會生成一個旁節點,表示它現在知道了 o
的形狀,這使得後續無需再次檢查該形狀。如果 Maglev 後來遇到因某種原因缺乏回饋的對 o
的操作,這類編譯期間學到的資訊可以作為第二來源的回饋加以利用。
運行時信息可以有多種形式。有些信息需要在運行時檢查,比如先前描述的形狀檢查。其他信息則可以在無需運行時檢查的情況下使用,通過向運行時註冊依賴實現。例如,事實上不變的全局變量(在 Maglev 看到它們的值之前未被修改)屬於這一類:Maglev 不需要生成動態加載和檢查其身份的代碼。Maglev 可以在編譯時加載該值並直接嵌入到機器碼中;如果運行時改變了該全局變量,它也會負責使該機器碼失效並進行去優化。
某些形式的信息是“不穩定的”。這種信息只能在編譯器確定不會變化的程度上使用。例如,如果我們剛分配了一個對象,我們知道它是新對象,可以完全跳過昂貴的寫屏障。當存在另一個潛在的分配後,垃圾回收器可能已將該對象移動,現在我們需要發出此類檢查。其他信息是“穩定的”:如果我們從未見過任何對象轉變成其他形狀,那麼我們可以對這一事件(任何對象從該特定形狀轉變)註冊依賴,甚至在調用不明副作用的未知函數後,也不需要重新檢查對象形狀。
去優化
由於 Maglev 可以在運行時檢查推測性資訊,Maglev 代碼需要能夠進行反優化。為了實現這一點,Maglev 將抽象解釋器框架狀態附加到可以反優化的節點上。該狀態將解釋器寄存器映射到 SSA 值。這些狀態在代碼生成期間轉化為元數據,提供了從優化狀態到未優化狀態的映射。反優化器解釋這些數據,從解釋器框架和機器寄存器中讀取值並將其放置到解釋需要的位置。這基於與 TurboFan 使用的相同反優化機制,讓我們能共享大部分邏輯並利用現有系統的測試成果。
表示選擇
根據 規範,JavaScript 數字表示為 64 位浮點值。但這並不意味著引擎必須始終將它們存儲為 64 位浮點數,尤其是在實際中許多數字是小整數(例如陣列索引)的情況下。V8 嘗試將數字編碼為 31 位標記整數(內部稱為 “Small Integers” 或 "Smi"),以節省內存(因 指針壓縮 而佔 32 位),並提高性能(整數操作比浮點操作更快)。
為了使大量使用數字運算的 JavaScript 代碼快速執行,為值節點選擇最佳表示非常重要。與解釋器和 Sparkplug 不同,優化編譯器在知道值類型後可以取消裝箱,操作原始數字而不是表示數字的 JavaScript 值,並且只有在絕對必要時才重新裝箱值。浮點數可以直接用浮點寄存器傳遞,而不是分配包含浮點數的堆對象。
Maglev 通過查看運行時反饋(例如二元操作),並通過已知節點資訊機制向前傳播該信息,來了解 SSA 節點的表示方式。當具有特定表示的 SSA 值流入 Phis 時,需要選擇支持所有輸入的正確表示。迴圈 Phis 更為棘手,因為來自迴圈內部的輸入在為 Phi 選擇表示之前才可以看到—這與圖構建的「回到過去」問題相同。這就是 Maglev 在圖構建後有一個單獨階段對迴圈 Phis 進行表示選擇的原因。
寄存器分配
在完成圖構建和表示選擇後,Maglev 大致知道它想要生成什麼樣的代碼,並從經典優化的角度來說已經 "完成" 了。然而,為了能夠生成代碼,我們需要選擇在執行機器代碼時 SSA 值實際存儲的位置;當它們在機器寄存器中和存儲於堆棧上時進行選擇。這是通過寄存器分配完成的。
每個 Maglev 節點都有輸入和輸出的要求,包括暫存器需求。寄存器分配器通過圖進行單次前向遍歷,維護一個與圖構建期間維護的抽象解釋狀態類似的抽象機器寄存器狀態,並滿足那些要求,將節點的要求替換為實際位置。這些位置隨後可以在代碼生成中使用。
首先,預掃描遍歷整個圖以找到節點的線性生命範圍,這樣我們就可以在一個 SSA 節點不再需要時釋放寄存器。此預掃描還跟蹤使用鏈。知道未來多久需要某個值可以幫助決定要優先處理哪些值以及當寄存器用完時需要丟棄哪些值。
在預掃描完成後,寄存器分配運行。寄存器分配遵循一些簡單的局部規則:如果一個值已經在寄存器中,則盡可能使用該寄存器。節點在圖遍歷期間跟蹤其存儲的位置。如果該節點尚未分配寄存器,但有空閒寄存器,則選擇該寄存器。更新節點以指示它在寄存器中,並更新抽象寄存器狀態以知道它包含該節點。如果沒有空閒寄存器但需要寄存器,便會將其它值推出寄存器。理想情況下,我們有一個已經在不同寄存器中的節點,並可以 "免費" 丟棄;否則我們選擇一個未來長時間不需要的值並將其溢出到堆棧。
在分支合併時,合併來自輸入分支的抽象寄存器狀態。我們盡量將更多的值保留在寄存器中。這可能意味著需要引入寄存器到寄存器的移動,或者需要從堆棧中取消溢出的值,使用稱為 "間隙移動" 的移位。如果分支合併處有 Phi 節點,寄存器分配將為 Phis 的輸出分配寄存器。Maglev 偏好將 Phis 輸出分配到與其輸入相同的寄存器,以最小化移動。
如果 SSA 值的存活數量超過了我們的暫存器數量,就需要將一些值溢出到堆疊,稍後再取消溢出。秉持 Maglev 的精神,我們盡量簡化這個過程:如果一個值需要被溢出,則會被追溯性地告知要在定義時(值被創建後立即)立刻溢出,並由代碼生成來處理溢出代碼的發射。定義保證會“支配”值的所有使用(要達到使用位置,我們必須先經過定義和溢出代碼)。這也意味著一個溢出的值在整段代碼中將只會有一個溢出槽;有重疊生命週期的值因而會被分配到不重疊的溢出槽。
由於表示方式的選擇,Maglev 框架中的某些值是標記指針,即 V8 的垃圾回收(GC)能理解且需要考慮的指針;也有一些未標記值,它們是 GC 不應該查看的值。TurboFan 通過準確記錄哪些堆疊槽包含標記值,哪些槽包含未標記值來解決這一問題,並且這些槽隨著執行過程中不同值的重複利用而改變。對 Maglev,我們決定簡化處理方式以減少跟蹤所需的內存:將堆疊框架分為標記區域和未標記區域,並只存儲這一分割點。
代碼生成
一旦我們知道希望對哪些表達式生成代碼,以及希望將它們的輸出和輸入放在哪裡,Maglev 就準備好生成代碼了。
Maglev 節點直接知道如何利用“宏彙編器”生成彙編代碼。例如,一個 CheckMap
節點知道如何發出彙編指令,將輸入對象的形狀(內部稱為“map”)與已知值進行比較,並在對象具有錯誤形狀時取消優化代碼。
代碼中的一個稍微棘手的部分處理了間隙移動:由暫存器分配器創建的請求移動,知道一個值存活於某處,需要移動到另一處。然而,如果有一系列這樣的移動,一個前置的移動可能會覆蓋隨後移動所需的輸入。並行移動解析器計算如何安全地執行這些移動,以確保所有值都到達正確的位置。
結果
我們剛介紹的這個編譯器明顯比 Sparkplug 複雜得多,但比 TurboFan 簡單得多。它表現如何呢?
我們設法構建了一個 JIT 編譯器,其編譯速度大約是 Sparkplug 的 10 倍慢,但比 TurboFan 快 10 倍。
這讓我們可以比部署 TurboFan 更早地部署 Maglev。如果它依賴的反饋最終還不是非常穩定,稍後進行取消優化和重新編譯的代價並不大。這同時讓我們可以稍晚些使用 TurboFan:我們的運行速度比使用 Sparkplug 快得多。
在 Sparkplug 和 TurboFan 中間插入 Maglev 帶來了明顯的基準測試改進:
我們還用真實世界的數據驗證了 Maglev 並在 核心網頁指標方面看到了良好的改進。
由於 Maglev 編譯得更快,且我們現在可以在用 TurboFan 編譯函數之前等待更久,因此產生了另一種不那麼直觀的好處。基準測試集中在主線程的延遲,但 Maglev 從整體上顯著減少了 V8 的資源消耗,因為它使用了更少的線程外 CPU 時間。在 M1 或 M2 設計的 MacBook 上,可以使用 taskinfo
輕鬆測量進程的能耗。
基準測試 | 能量消耗 |
---|---|
JetStream | -3.5% |
Speedometer | -10% |
Maglev 還遠未完成。我們仍有很多工作要做,還有更多的想法要嘗試,更多容易解決的問題要處理——隨著 Maglev 越來越完善,預期可以看到更高的得分和更低的能耗。
Maglev 現在已經適用於桌面版 Chrome,並將很快推送至移動設備。