「譯」Liftoff:全新的 WebAssembly 編譯器

「譯」Liftoff:全新的 WebAssembly 編譯器

來自專欄 ES2049 Studio1 人贊了文章

翻譯自: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 的調用約定,要將這兩個參數傳入 raxrdx 兩個寄存器。

對於 get_local 指令,Liftoff 不會實際生成任何代碼,而只是對他的內部狀態進行更新,以反映這些寄存器值已被入棧到虛擬棧中。然後 i32.add 指令出棧了這兩個寄存器,並且為結果值選擇一個寄存器。我們不能選擇兩個入參寄存器中的任何一個來給結果值使用,因為這兩個寄存器都還作為存放本地變數的位置出現在棧上。覆蓋他們會導致後面的 get_local 指令返回不正確的值。因此 Liftoff 會選擇一個新的寄存器(在例子中是 rcx)然後將計算出的 raxrdx 的和寫入這個寄存器。之後 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 上做應用,不過其中的權衡點可能會稍有不同:

  1. WebAssembly 不需要類型反饋來生成更快的代碼。JavaScript 的優化有很多是得益於類型反饋的,但 WebAssembly 是靜態類型的,所以引擎可以獨立生成優化代碼。
  2. WebAssembly 代碼必須在一個可預期的高速狀態下運行,不能有一個長時間的熱身階段。應用選擇使用 WebAssembly 的眾多原因之一就是可以以一個可預期的高性能運行在 web 上。所以我們即不能容忍代碼在次優化狀態下運行太久,也不能允許運行時編譯引發的暫停。
  3. 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三個,明明是四個)

  1. EpicZenGarden: The ZenGarden demo running on the Epic framework: s3.amazonaws.com/mozill
  2. Tanks!: A demo of the Unity engine: webassembly.org/demo/
  3. AutoDesk: web.autocad.com/
  4. PSPDFKit: pspdfkit.com/webassembl

每一個 benchmark 我們都會記錄下追蹤工具測量出的編譯時長。這個數字會比 benchmark 自己跑出來的時長更加穩定,因為他不和某個主線程上註冊的任務相關聯,也不會包含任何類似創建 WebAssembly 實例這樣無關的動作。

下圖展示了這些 benchmark 的結果,每一個 benchmark 我們都重複跑了三次並對結果取平均數。

如我們所預期的,Liftoff 編譯器不管是在高配置的桌面工作站還是 Macbook 上都有著更加快的代碼生成速度。即使是在低性能的 MAcbook 硬體上,Liftoff 相比 TurboFan 的提速效果也要遠遠大得多。

產出代碼的運行性能

雖然產出代碼的運行性能是我們的二級目標,但畢竟 Liftoff 的代碼在 TurboFan 生成代碼之前還是很可能要跑個幾秒幾十秒的,所以我們還是期望能在啟動階段提供一個高性能的用戶體驗。

為了測量 Liftoff 產出的代碼的性能,我們關閉了自動升級,以求檢測純 Liftoff 代碼的運行狀態。在這個設定下,我們跑了兩個 benchmark:

  1. Unity headless benchmarks

    這是一系列在 Unity 框架下運行的 benchmark。他們是無 UI 的,因此可以直接在 d8 shell 下運行。每一個 benchmark 會統計出一個得分,雖然這個分數並不一定是成比例得反應性能的,但已經足夠用來比較性能了。

  2. PSPDFKit: pspdfkit.com/webassembl

    這個 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 帶來的收益普惠到更多用戶身上。從具體內容上來說,我們正在對下面這些內容進行優化:

  1. 將 Liftoff 移植到 arm 與 arm64 上,使移動設備也可以使用他。目前,Liftoff 只針對 Intel 的平台(32位與64位)做了實現,覆蓋了大部分桌面端的用戶。為了覆蓋到移動端的用戶,我們會移植 Liftoff 到更多的架構上。
  2. 為移動設備實現一套動態升級。因為移動設備相比桌面系統傾向於擁有更少的內存空間,我們需要為這些設備適配一套升級策略。只是用 TurboFan 重新編譯所有函數的話很容易就會因為要存儲那些代碼造成內存的雙倍消耗,至少一段時間內會出現這種情況(在 Liftoff 代碼被棄置前)。所以我們正在實驗一種 Liftoff 懶編譯與高頻函數動態升級到 TurboFan 的組合。
  3. 提高 Liftoff 產出代碼的性能。第一次迭代的產物一般都不是最好的。還有不少東西有待調整,他們可以使 Liftoff 的編譯速度上升更多。這些內容我們將在以後的發布中逐步帶給大家。
  4. 提高 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 。

推薦閱讀:

2018 年,WebAssembly(技術周刊 2018-04-20)
官方:我們對Rust和WebAssembly的願景
從 Haskell 到 WebAssembly(1)
教你手寫WASM

TAG:WebAssembly | 編譯器後端 | 編譯器 |