「譯」Liftoff:V8 引擎中全新的 WebAssembly baseline 編譯器

v8-logo

翻譯自: Liftoff: a new baseline compiler for WebAssembly in V8

Monday, August 20, 2018html

V8 引擎在 v6.9 版本中加入了一個全新的 WebAssembly baseline 編譯器 —— Liftoff。它目前在桌面系統平臺上是默認開啓的。本文將會詳細講解引入新的編譯層的動機,並介紹一下 Liftoff 的具體實現以及性能狀況。web

在 WebAssembly 開始發展的這一年多時間裏,其在 web 上的應用一直在穩步發展。採用 WebAssembly 技術的大型應用已開始出現。例如 Epic 的 ZenGarden benchmark 推出了一版 39.5 MB 的 WebAssembly 二進制包,以及 AutoDesk 推出了一版 36.8 MB 的二進制包。由於編譯時間基本上是相對包大小線性增加的,因此這些應用都須要花費至關長的時間在啓動上。在許多機器上甚至會超過 30 秒,這可不是一個很好的用戶體驗。chrome

爲何一個 WebAssembly 應用啓動要花這麼久的時間,而一個相似的 JS 應用相比之下能夠很快啓動呢?緣由是 WebAssembly 須要保證提供一個可預期的性能,這樣你的應用啓動後就能夠穩定得達到預期的運行性能。(好比每秒渲染 60 幀,無音頻延遲等等...)。爲了達到這一目標,V8 對 WebAssembly 代碼會提早編譯,這樣就能夠避免任何運行時編譯器引發的編譯暫停讓應用發生可感知的卡頓。shell

現存的編譯管線(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-01

從上面這張圖表能夠很明顯地看出 Liftoff 會比 TurboFan 產出代碼的速度快不少,由於它的管線只由兩個步驟組成。事實上,函數體解碼器(Function Body Decoder)只對源 WebAssmebly 字節碼作一次處理,並經過回調方式與後面的步驟進行交互,因此代碼生成是在解碼與校驗函數體的時候同時執行的。再結合上 WebAssembly 的流式(streaming)API,可讓 V8 在從網絡上下載代碼的同時將 WebAssembly 代碼編譯成機器碼。架構

Liftoff 的代碼生成

Liftoff 是一款簡單高效的代碼生成器。它只對函數內的操做(opcode)作一輪處理,將操做轉換成代碼,一次一個。像計算這樣的簡單操做,通常就對應一個機器指令,可是像調用這樣的操做就會對應更多的機器指令。Liftoff 維護着一個操做數棧的元數據,用以知曉每一個操做的輸入正被存儲在什麼位置。這個虛擬棧僅存在於編譯期間。WebAssembly 的結構化控制流與校驗規則保證了這些輸入的位置能夠被靜態肯定。這樣就再也不須要一個用於入棧出棧操做元的真實運行時棧了。在運行期間,虛擬棧上的每一個值會被存儲於寄存器或者是被溢出到那個函數的物理棧幀。那些小的整型常量(由 i32.const 建立),Liftoff 只會將他的常量值記錄在虛擬棧上,而不會生成任何代碼。只有當這個常量被用於隨後的一個操做,他會被與這個操做一塊兒發出或組合,例如在 x64 上直接發出一個 addl <reg>, <const> 指令。這避免了將這個值寫入寄存器的操做,產出了更爲簡潔的代碼。框架

讓咱們來看一個很是簡單的函數,來看下 Liftoff 是如何生成代碼的。less

liftoff-02

這個範例函數接受兩個參數而後返回他們的和。當 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 只須要生成代碼去重排一下寄存器與物理棧幀上的值就能夠了。

讓咱們看一下以下例子。

liftoff-03

上面的例子設定了一個擁有兩個值的操做數棧的虛擬棧。在開始新區塊以前,虛擬棧最頂端的值被出棧用做 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-04

性能

有兩個指標在咱們評估新的 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: https://s3.amazonaws.com/mozilla-games/ZenGarden/EpicZenGarden.html
  2. Tanks!: A demo of the Unity engine: https://webassembly.org/demo/
  3. AutoDesk: https://web.autocad.com/
  4. PSPDFKit: https://pspdfkit.com/webassembly-benchmark/

每個 benchmark 咱們都會記錄下追蹤工具測量出的編譯時長。這個數字會比 benchmark 本身跑出來的時長更加穩定,由於他不和某個主線程上註冊的任務相關聯,也不會包含任何相似建立 WebAssembly 實例這樣無關的動做。

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

liftoff-05

liftoff-06

如咱們所預期的,Liftoff 編譯器無論是在高配置的桌面工做站仍是 Macbook 上都有着更加快的代碼生成速度。即便是在低性能的 MAcbook 硬件上,Liftoff 相比 TurboFan 的提速效果也要遠遠大得多。

產出代碼的運行性能

雖然產出代碼的運行性能是咱們的二級目標,但畢竟 Liftoff 的代碼在 TurboFan 生成代碼以前仍是極可能要跑個幾秒幾十秒的,因此咱們仍是指望能在啓動階段提供一個高性能的用戶體驗。

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

  1. Unity headless benchmarks

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

  2. PSPDFKit: https://pspdfkit.com/webassembly-benchmark/

    這個 benchmark 會統計對 pdf 文檔作各類操做的時間開銷,以及 WebAssembly 模塊的實例化時間(包含編譯)

和以前同樣,咱們會每一個 benchmark 跑三次而後取平均值。由於 benchmark 結果數值的差別很是得明顯,咱們在這裏選擇展現 Liftoff 與 TurboFan 的相對性能。+30% 表明 Liftoff 的代碼要比 TurboFan 的代碼慢 30%。負值則表明着 Liftoff 的代碼更快一些。下面咱們來看結果:

liftoff-07

執行 Unity 時,在臺式機上 Liftoff 的代碼要比 TurboFan 的代碼平均慢 50%,在 Macbook 上平均慢 70%。有趣的是,你會發現有一個狀況下(Mandelbrot Script)Liftoff 的代碼性能要比 TurboFan 的代碼好。這極可能是一個異常狀況,例如 TurboFan 的寄存器分配器在一個高頻循環中表現得不是很好。咱們正在研究是否有什麼辦法讓 TurboFan 能更好得處理這種狀況。

liftoff-08

執行 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 。
相關文章
相關標籤/搜索