翻譯自:Liftoff: a new baseline compiler for WebAssembly in V8
Monday, August 20, 2018
V8 引擎在 v6.9 版本中加入了一個全新的 WebAssembly baseline 編譯器 —— Liftoff。它目前在桌面系統平臺上是預設開啟的。本文將會詳細講解引入新的編譯層的動機,並介紹一下 Liftoff 的具體實現以及效能情況。
在 WebAssembly 開始發展的這一年多時間裡,其在 web 上的應用一直在穩步發展。採用 WebAssembly 技術的大型應用已開始出現。例如 Epic 的 ZenGarden benchmark 推出了一版 39.5 MB 的 WebAssembly 二進位制包,以及 AutoDesk 推出了一版 36.8 MB 的二進位制包。因為編譯時間基本上是相對包大小線性增長的,所以這些應用都需要花費相當長的時間在啟動上。在許多機器上甚至會超過 30 秒,這可不是一個很好的使用者體驗。
為什麼一個 WebAssembly 應用啟動要花這麼久的時間,而一個類似的 JS 應用相比之下可以很快啟動呢?原因是 WebAssembly 需要保證提供一個可預期的效能,這樣你的應用啟動後就可以穩定得達到預期的執行效能。(比如每秒渲染 60 幀,無音訊延遲等等…)。為了達到這一目標,V8 對 WebAssembly 程式碼會提前編譯,這樣就可以避免任何執行時編譯器引起的編譯暫停讓應用發生可感知的卡頓。
現存的編譯管線(TurboFan)
V8 過去對 WebAssembly 的編譯是基於 TurboFan 的。TurboFan 是專為 JavaScript 和 asm.js 設計的優化編譯器。他是一款功能強大的編譯器,內部使用一種基於圖的中間表達(IR),其適用於進一步的優化,例如強度折減(strength reduction)、內聯(inlining)、程式碼外提(code motion)、指令合併(instruction combining)、精密暫存器分配(sophisticated register allocation)。TurboFan 的設計支援在整個管線的很後面才會引入,接近機器碼這邊,所以會跳過許多幫助 JavaScript 編譯的必要步驟。因為設計原因,通過一個單次前向處理來將 WebAssembly 程式碼轉換到 TurboFan 的 IR(包含 SSA-構造)是非常有效率的,部分是因為 WebAssembly 結構化的控制流。不過編譯程式後臺仍然要消耗相當多的時間與記憶體。
新的編譯管線(Liftoff)
Liftoff 的目標是通過儘快生成可執行程式碼來縮減 WebAssembly 應用的啟動時間。程式碼的質量則是放在第二位的,畢竟 “hot” 的程式碼還是會被 TurboFan 再編譯一次的。Liftoff 在對 WebAssembly 函式的位元組碼的單次處理中,規避了因構建 IR 和生成機器碼發生的時間與記憶體開銷。
從上面這張圖表可以很明顯地看出 Liftoff 會比 TurboFan 產出程式碼的速度快很多,因為它的管線只由兩個步驟組成。事實上,函式體解碼器(Function Body Decoder)只對源 WebAssmebly 位元組碼做一次處理,並通過回撥方式與後面的步驟進行互動,所以程式碼生成是在解碼與校驗函式體的時候同時執行的。再結合上 WebAssembly 的流式(streaming)API,可以讓 V8 在從網路上下載程式碼的同時將 WebAssembly 程式碼編譯成機器碼。
Liftoff 的程式碼生成
Liftoff 是一款簡單高效的程式碼生成器。它只對函式內的操作(opcode)做一輪處理,將操作轉換成程式碼,一次一個。像計算這樣的簡單操作,一般就對應一個機器指令,但是像呼叫這樣的操作就會對應更多的機器指令。Liftoff 維護著一個運算元棧的後設資料,用以知曉每個操作的輸入正被儲存在什麼位置。這個虛擬棧僅存在於編譯期間。WebAssembly 的結構化控制流與校驗規則保證了這些輸入的位置可以被靜態確定。這樣就不再需要一個用於入棧出棧操作元的真實執行時棧了。在執行期間,虛擬棧上的每個值會被儲存於暫存器或者是被溢位到那個函式的物理棧幀。那些小的整型常量(由 i32.const 建立),Liftoff 只會將他的常量值記錄在虛擬棧上,而不會生成任何程式碼。只有當這個常量被用於隨後的一個操作,他會被與這個操作一起發出或組合,例如在 x64 上直接發出一個 addl , 指令。這避免了將這個值寫入暫存器的操作,產出了更為簡潔的程式碼。
讓我們來看一個非常簡單的函式,來看下 Liftoff 是如何生成程式碼的。
這個範例函式接受兩個引數然後返回他們的和。當 Liftoff 解碼這個函式的位元組碼時,他先根據 WebAssembly 的函式呼叫約定為本地變數初始化他的內部狀態。拿 x64 來說,依照 V8 的呼叫約定,要將這兩個引數傳入 rax 和 rdx 兩個暫存器。
對於 get_local 指令,Liftoff 不會實際生成任何程式碼,而只是對他的內部狀態進行更新,以反映這些暫存器值已被入棧到虛擬棧中。然後 i32.add 指令出棧了這兩個暫存器,並且為結果值選擇一個暫存器。我們不能選擇兩個入參暫存器中的任何一個來給結果值使用,因為這兩個暫存器都還作為存放本地變數的位置出現在棧上。覆蓋他們會導致後面的 get_local 指令返回不正確的值。因此 Liftoff 會選擇一個新的暫存器(在例子中是 rcx)然後將計算出的 rax 和 rdx 的和寫入這個暫存器。之後 rcx 會被入棧到虛擬棧中。
在 i32.add 指令之後,函式體結束了,Liftoff 此時需要開始準備返回內容了。範例中的函式只有一個返回值,所以校驗環節需要保證在函式體結束時虛擬棧上只有一個值。因此 Liftoff 生成程式碼將返回值從 rcx 移動到更合適的返回值暫存器 rax 然後從函式中返回出來。
為了讓例子儘量簡單,上面的程式碼並沒有涉及任何區塊(if, loop …)或者是分支。在 WebAssembly 中,由於程式碼可以分支到任何父區塊並且 if-區塊可以被跳過,所以區塊引入了控制合併。這些合併點可能會在多種不同的棧狀態下被執行到。然而後面的程式碼必須基於一個確定的狀態去生成。因此 Liftoff 會將虛擬棧當前的狀態儲存為快照,這個狀態會作為新區塊之後的程式碼(回到當前所在的控制層級的時候)的狀態。然後新區塊繼續使用當前的狀態,可能後面會更改棧值或者是本地值的儲存位置:有一些可能會溢位到棧上或者是被放到別的暫存器上。當分支到另一個區塊或者結束了一個區塊(也可以理解為分支到了父級區塊)時,Liftoff 需要生成程式碼去將當前狀態轉換到那個點上期望的狀態,這些程式碼執行後可以讓之後的程式碼在其期望的位置找到正確的值。校驗環節保證了虛擬棧的高度與所期望的狀態下的高度是相等的,因此 Liftoff 只需要生成程式碼去重排一下暫存器與物理棧幀上的值就可以了。
讓我們看一下如下例子。
上面的例子設定了一個擁有兩個值的運算元棧的虛擬棧。在開始新區塊之前,虛擬棧最頂端的值被出棧用作 if 指令的引數。棧上剩下的一個值需要被放到另一個暫存器去,因為他現在實際指向的是與第一個函式引數相同的暫存器,但當我們之後回到現在狀態的時候,這個棧上的值與引數值我們很可能需要存為兩個不同的值。這種情況下,Liftoff 會複製一份值到暫存器 rcx 。之後這個狀態就會被快照儲存,後面區塊的程式碼會對當前狀態繼續進行修改。在這個區塊結束時,我們一定會分支回到父區塊,所以我們將當前狀態合併到快照上,具體做法就是將 rbx 的值遷移到 rcx 上,然後將 rdx 的值從棧幀上載入回來。
從 Liftoff 到 TurboFan 的層級提升(Tiering up)
有了 Liftoff 和 TurboFan,現在 V8 引擎針對 WebAssembly 有兩個編譯層級了:Liftoff 作為 baseline 編譯器提供快速啟動的能力,TurboFan 作為優化編譯器提供最佳效能。這就帶來了一個問題,如何協調使用這兩個編譯器以帶來全域性最佳的使用者體驗。
在 JavaScript 中,V8 使用了 Ignition 直譯器與 TurboFan 優化編譯器並通過一個動態升級策略(dynamic tier-up)進行調配。每一個函式首先都會在直譯器上執行,當這個函式變得經常被執行(hot)時,TurboFan 會將其編譯為高度優化的機器碼執行。相同的方法也可以在 Liftoff 上做應用,不過其中的權衡點可能會稍有不同:
- WebAssembly 不需要型別反饋來生成更快的程式碼。JavaScript 的優化有很多是得益於型別反饋的,但 WebAssembly 是靜態型別的,所以引擎可以獨立生成優化程式碼。
- WebAssembly 程式碼必須在一個可預期的高速狀態下執行,不能有一個長時間的熱身階段。應用選擇使用 WebAssembly 的眾多原因之一就是可以以一個可預期的高效能執行在 web 上。所以我們即不能容忍程式碼在次優化狀態下執行太久,也不能允許執行時編譯引發的暫停。
- JavaScript 的 Ignition 直譯器的重要設計目標之一就是通過不用編譯所有函式來減少記憶體的開銷。然而我們發現一個 WebAssembly 直譯器實在是太慢了,完全無法滿足我們提供可預期高效能的目標。事實上,我們還真寫過一個直譯器,不管他節約了多少空間,他比執行編譯後程式碼至少慢了20倍甚至更多,只能說他在 debug 時還有點用。因為這些原因,引擎不得不儲存編譯後程式碼;最後他應該只會儲存那些最為精簡高效的程式碼,那就是 TurboFan 優化後的程式碼。
從以上這些限制,我們可以發現動態升級對於當前 V8 對 WebAssembly 的優化實現並不是最佳的權衡點,因為這會引發程式碼大小的增加以及在一個不確定時間段內的效能縮水。在這裡我們選擇了另一個策略,叫做飢渴升級(eager tier-up)。在 Liftoff 完成對一個模組的編譯之後,緊跟著,WebAssembly 引擎會拉起一個後臺執行緒開始生成該模組的優化程式碼。這種策略使 V8 可以快速得開始執行程式碼(在 Liftoff 完成編譯後),並且依然能夠儘早地讓程式碼執行在 TurboFan 優化後的效能下。
下面這張圖片展示了在編譯與執行 the EpicZenGarden benchmark 時的跟蹤資訊。圖上顯示,在 Liftoff 完成編譯之後,我們就可以例項化 WebAssembly 模組並開始執行。TurboFan 的編譯在這之後還需要一點時間完成,因此在這段升級過程的時間區間內,我們可以觀察到執行效能在逐漸地提升,得益於單獨的 TurboFan 函式可以在他們完成編譯之後就馬上投入使用。
效能
有兩個指標在我們評估新的 Liftoff 編譯器的效能時是非常感興趣的。第一個是我們會比較他和 TurboFan 在編譯速度(生成程式碼的用時)上的差異。第二個是我們會測量生成出的程式碼的執行效能(執行速度)。兩者中第一個指標是我們更為關注的,畢竟 Liftoff 的最重要目標就是儘快生成程式碼來縮減應用啟動時間。另一方面,生成出的程式碼的執行效能也需要是比較不錯的,因為這些程式碼可能會需要執行幾秒幾十秒,在一些低效能硬體上甚至可能是幾分鐘。
生成程式碼效能
為了測量編譯器效能,我們會執行幾個 benchmark 並通過追蹤(如上圖所示)測量編譯時間。我們會在一臺 HP Z840 機器(2 x Intel Xeon E5-2690 @2.6GHz, 24 cores, 48 threads)和一臺 Macbook Pro(Intel Core i7-4980HQ @2.8GHz, 4 cores, 8 threads)上進行 benchmark 測試。注意 Chrome 目前不會使用超過 10 個後臺執行緒,因此 Z840 的大部分核心是不會被用到的。
我們執行了三個 benchmark:(神tm三個,明明是四個)
- EpicZenGarden: The ZenGarden demo running on the Epic framework: s3.amazonaws.com/mozilla-gam…
- Tanks!: A demo of the Unity engine: webassembly.org/demo/
- AutoDesk: web.autocad.com/
- PSPDFKit: pspdfkit.com/webassembly…
每一個 benchmark 我們都會記錄下追蹤工具測量出的編譯時長。這個數字會比 benchmark 自己跑出來的時長更加穩定,因為他不和某個主執行緒上註冊的任務相關聯,也不會包含任何類似建立 WebAssembly 例項這樣無關的動作。
下圖展示了這些 benchmark 的結果,每一個 benchmark 我們都重複跑了三次並對結果取平均數。
如我們所預期的,Liftoff 編譯器不管是在高配置的桌面工作站還是 Macbook 上都有著更加快的程式碼生成速度。即使是在低效能的 MAcbook 硬體上,Liftoff 相比 TurboFan 的提速效果也要遠遠大得多。
產出程式碼的執行效能
雖然產出程式碼的執行效能是我們的二級目標,但畢竟 Liftoff 的程式碼在 TurboFan 生成程式碼之前還是很可能要跑個幾秒幾十秒的,所以我們還是期望能在啟動階段提供一個高效能的使用者體驗。
為了測量 Liftoff 產出的程式碼的效能,我們關閉了自動升級,以求檢測純 Liftoff 程式碼的執行狀態。在這個設定下,我們跑了兩個 benchmark:
-
Unity headless benchmarks
這是一系列在 Unity 框架下執行的 benchmark。他們是無 UI 的,因此可以直接在 d8 shell 下執行。每一個 benchmark 會統計出一個得分,雖然這個分數並不一定是成比例得反應效能的,但已經足夠用來比較效能了。
-
PSPDFKit: pspdfkit.com/webassembly…
這個 benchmark 會統計對 pdf 文件做各種操作的時間開銷,以及 WebAssembly 模組的例項化時間(包含編譯)
和之前一樣,我們會每個 benchmark 跑三次然後取平均值。因為 benchmark 結果數值的差異非常得明顯,我們在這裡選擇展示 Liftoff 與 TurboFan 的相對效能。+30% 代表 Liftoff 的程式碼要比 TurboFan 的程式碼慢 30%。負值則代表著 Liftoff 的程式碼更快一些。下面我們來看結果:
執行 Unity 時,在桌上型電腦上 Liftoff 的程式碼要比 TurboFan 的程式碼平均慢 50%,在 Macbook 上平均慢 70%。有趣的是,你會發現有一個情況下(Mandelbrot Script)Liftoff 的程式碼效能要比 TurboFan 的程式碼好。這很可能是一個異常情況,例如 TurboFan 的暫存器分配器在一個高頻迴圈中表現得不是很好。我們正在研究是否有什麼辦法讓 TurboFan 能更好得處理這種情況。
執行 PSPDFKit benchmark 時,Liftoff的程式碼要比優化後的程式碼慢上 18-54%,不過就如我們所期望的,在初始化這塊上有著顯著的提升。這些數字告訴我們,對於那些真實專案的程式碼(可能會通過 JavaScript 呼叫與瀏覽器進行互動的),未優化程式碼的效能損失通常都要比那些計算集中型 benchmark 的程式碼損失得少。
並且在這裡要再宣告一下,這個結果是我們在完全關閉了升級策略的情況下跑出來的,我們只執行了 Liftoff 的程式碼。在生產版本的配置下,Liftoff 的程式碼會在執行時逐漸被 TurboFan 的程式碼替代,因此低效能的 Liftoff 程式碼只會執行很短的一段時間。
接下去要做的
在最初 Liftoff 專案啟動後,我們就一直致力於改善啟動耗時,減少記憶體消耗,以及將 Liftoff 帶來的收益普惠到更多使用者身上。從具體內容上來說,我們正在對下面這些內容進行優化:
- 將 Liftoff 移植到 arm 與 arm64 上,使移動裝置也可以使用他。目前,Liftoff 只針對 Intel 的平臺(32位與64位)做了實現,覆蓋了大部分桌面端的使用者。為了覆蓋到移動端的使用者,我們會移植 Liftoff 到更多的架構上。
- 為移動裝置實現一套動態升級。因為移動裝置相比桌面系統傾向於擁有更少的記憶體空間,我們需要為這些裝置適配一套升級策略。只是用 TurboFan 重新編譯所有函式的話很容易就會因為要儲存那些程式碼造成記憶體的雙倍消耗,至少一段時間內會出現這種情況(在 Liftoff 程式碼被棄置前)。所以我們正在實驗一種 Liftoff 懶編譯與高頻函式動態升級到 TurboFan 的組合。
- 提高 Liftoff 產出程式碼的效能。第一次迭代的產物一般都不是最好的。還有不少東西有待調整,他們可以使 Liftoff 的編譯速度上升更多。這些內容我們將在以後的釋出中逐步帶給大家。
- 提高 Liftoff 產出的程式碼的執行效能。除開編譯器本身,他產出的程式碼在大小與執行速度上依然有著提升空間。這些我們也會在之後的釋出中逐步加入。
總結
V8 目前已包含了 Liftoff 這一新款 WebAssembly baseline 編譯器。Liftoff 他簡單快速的程式碼生成器極大地提升了 WebAssembly 應用的啟動速度。在桌面系統上,V8 依然會通過讓 TurboFan 在後臺重新編譯程式碼的方式最終讓程式碼執行效能達到峰值。V8 v6.9 (Chrome 69) 中 Liftoff 已經設定為預設工作狀態,也可以顯式地通過 –liftoff/–no-liftoff 或者 chrome://flags/#enable-webassembly-baseline 開關來控制。
本文作者:Clemens Hammacher, WebAssembly compilation maestro
文章可隨意轉載,但請保留此 原文連結。
非常歡迎有激情的你加入 ES2049 Studio,簡歷請傳送至 caijun.hcj(at)alibaba-inc.com 。