[譯] 使 WebAssembly 更快:Firefox 的新流式和分層編譯器

SamChord發表於2018-10-17

人們都說 WebAssembly 是一個遊戲規則改變者,因為它可以讓程式碼更快地在網路上執行。有些加速已經存在,還有些在不遠的將來。

其中一種加速是流式編譯,即瀏覽器在程式碼還在下載的時候就對其進行編譯。截至目前,這只是潛在的未來加速(方式)。但隨著下週 Firefox 58 版本的釋出,它將成為現實。

Firefox 58 還包含兩層新的編譯器。新的基線編譯器編譯程式碼的速度比優化編譯器快了 10-15 倍。

綜合起來,這兩個變化意味著我們編譯程式碼的速度比從網路中編譯程式碼速度快。

[譯] 使 WebAssembly 更快:Firefox 的新流式和分層編譯器

在臺式電腦上,我們每秒編譯 30-60 兆位元組的 WebAssembly 程式碼。這比網路傳送資料包的速度還快。

如果你使用 Firefox Nightly 或者 Beta,你可以在你自己裝置上試一試。即便是在很普通的移動裝置上,我們可以每秒編譯 8 兆位元組 —— 這比任何行動網路的平均下載速度都要快得多。

這意味著你的程式碼幾乎是在它完成下載後就立即執行。

為什麼這很重要?

當網站釋出大批量 JavaScript 程式碼時,Web 效能擁護者會變得束手無策。這是因為下載大量的 JavaScript 會讓頁面載入變慢。

這很大程度是因為解析和編譯時間。正如 Steve Souder 指出,網路效能的舊瓶頸曾是網路。但現在網路效能的新瓶頸是 CPU,特別是主執行緒。

Old bottleneck, the network, on the left. New bottleneck, work on the CPU such as compiling, on the right

所以我們想要儘可能多的把工作從主執行緒中移除。我們也想要儘可能早的啟動它,以便我們充分利用 CPU 的所有時間。更好的是,我們可以完全減少 CPU 工作量。

使用 JavaScript 時,你可以做一些這樣的事情。你可以通過流入的方式在主執行緒外解析檔案。但你還是需要解析它們,這就需要很多工作,並且你必須等到它們都解析完了才能開始編譯。然後編譯的時候,你又回到了主執行緒上。這是因為 JS 通常是執行時延遲編譯的。

Timeline showing packets coming in on the main thread, then parsing happening simultaneously on another thread. Once parse is done, execution begins on main thread, interrupted occassionally by compiling

使用 WebAssembly,啟動的工作量減少了。解碼 WebAssembly 比解析 JavaScript 更簡單,更快捷。並且這些解碼和編譯可以跨多個執行緒進行拆分。

這意味著多個執行緒將執行基線編譯,這會讓它變得更快。一旦完成,基線編譯好的程式碼就可以在主執行緒上開始執行。它不必像 JS 程式碼一樣暫停編譯。

Timeline showing packets coming in on the main thread, and decoding and baseline compiling happening across multiple threads simultaneously, resulting in execution starting faster and without compiling breaks.

當基線編譯的程式碼在主執行緒上執行時,其他執行緒則在做更優化的版本。當更優化的版本完成時,它就會替換進來使得程式碼執行更加快捷。

這使得載入 WebAssembly 的成本變得更像解碼圖片而不是載入 JavaScript。並且想想看 —— 網路效能倡導者肯定接受不了 150kB 的 JS 程式碼負載量,但相同大小的影象負載量並不會引起人們的注意。

Developer advocate on the left tsk tsk-ing about large JS file. Developer advocate on the right shrugging about large image.

這是因為影象的載入時間要快得多,就像 Addy Osmani 在 JavaScript 的成本 中解釋的那樣,解碼影象並不會阻塞主執行緒,正如 Alex Russell 在你能接受嗎?真實的 Web 效能預算中所討論的那樣。

但這並不意味著我們希望 WebAssembly 檔案和影象檔案一樣大。雖然早期的 WebAssembly 工具建立了大型的檔案,是因為它們包含了很多執行時(內容),目前來看還有很多工作要做讓檔案變得更小。例如,Emscripten 有一個“縮小協議”。在 Rust 中,你已經可以通過使用 wasm32-unknown-unknown 目標來獲取相當小尺寸的檔案,並且還有像 wasm-gcwasm-snip 這樣的工具來幫助進一步優化它們。

這就意味著這些 WebAssembly 檔案的載入速度要比等量的 JavaScript 快得多。

這很關鍵。正如 Yehuda Katz 指出,這是一個遊戲規則改變者。

Tweet from Yehuda Katz saying it's possible to parse and compile wasm as fast as it comes over the network.

所以讓我們看看新編譯器是怎麼工作的吧。

流式編譯:更早開始的編譯

如果你更早開始編譯程式碼,你就更早完成它。這就是流式編譯所做的 —— 儘可能快地開始編譯 .wasm 檔案。

當你下載檔案時,它不是單件式的。實際上,它帶來的是一系列資料包。

之前,當 .wasm 檔案中的每個包正在下載時,瀏覽器網路層會把它放進 ArrayBuffer(譯者注:陣列快取)中。

Packets coming in to network layer and being added to an ArrayBuffer

然後,一旦完成下載,它會將 ArrayBuffer 轉移到 Web VM(也就是 JS 引擎)中。也就到了 WebAssembly 編譯器要開始編譯的時候。

Network layer pushing array buffer over to compiler

但是沒有充分的理由讓編譯器等待。從技術上講,逐行編譯 WebAssembly 是可行的。這意味著你能夠在第一個塊進來的時候就開始啟動。

所以這就是我們新編譯器所做的。它利用了 WebAssembly 的流式 API。

WebAssembly.instantiateStreaming call, which takes a response object with the source file. This has to be served using MIME type application/wasm.

如果你提供給 WebAssembly.instantiateStreaming 一個響應的物件,則(物件)塊一旦到達就會立即進入 WebAssembly 引擎。然後編譯器可以開始處理第一個塊,即便下一個塊還在下載中。

Packets going directly to compiler

除了能夠並行下載和編譯程式碼外,它還有另外一個優勢。

.wasm 模組中的程式碼部分位於任何資料(它將引入到模組的記憶體物件)之前。因此,通過流式傳輸,編譯器可以在模組的資料仍在下載的時候就對其進行編譯。如果當你的模組需要大量的資料,且可能是兆位元組的時候,這些就會顯得很重要。

File split between small code section at the top, and larger data section at the bottom

通過流式傳輸,我們可以提前開始編譯。而且我們同樣可以更快速地進行編譯。

第 1 層基線編譯器:更快的編譯程式碼

如果你想要程式碼跑的快,你就需要優化它。但是當你編譯時執行這些優化會花費時間,也就會讓編譯程式碼變得更慢。所以這裡需要一個權衡。

但魚和熊掌可以兼得。如果我們使用兩個編譯器,就能讓其中一個快速編譯但是不做過多的優化工作,而另一個雖然編譯慢,但是建立了更多優化的程式碼。

這就稱作為層編譯器。當程式碼第一次進入時,將由第 1 層(或基線)編譯器對其編譯。然後,當基線編譯完成,程式碼開始執行之後,第 2 層編譯器再一次遍歷程式碼並在後臺編譯更優化的版本。

一旦它(譯者注:第 2 層編譯)完成,它會將優化後的程式碼熱插拔為先前的基線版本。這使程式碼執行得更快。

Timeline showing optimizing compiling happening in the background.

JavaScript 引擎已經使用分層編譯器很長一段時間了。然而,JS 引擎只在一些程式碼變得“溫熱” —— 當程式碼的那部分被呼叫太多次時,才會使用第 2 層(或優化)編譯器。

相比之下,WebAssembly 的第 2 層編譯器會熱切地進行全面的重新編譯,優化模組中的所有程式碼。在未來,我們可能會為開發者新增更多選項,用來控制如何進行激進的優化或者惰性的優化。

基線編譯器在啟動時節省了大量時間。它編譯程式碼的速度比優化編譯器的快 10-15 倍。並且在我們的測試中,它建立程式碼的速度只慢了 2 倍。

這意味著,只要仍在執行基線編譯程式碼,即便是在最開始的幾分鐘你的程式碼也會執行地很快。

並行化:讓一切更快

關於 Firefox Quantum 的文章中,我解釋了粗粒度和細粒度的並行化。我們可以用它們來編譯 WebAssembly。

我在上文有提到,優化編譯器會在後臺進行編譯。這意味著它空出的主執行緒可用於執行程式碼。基線編譯版本的程式碼可以在優化編譯器進行重新編譯時執行。

但在大多數電腦上仍然會有多個核心沒有使用。為了充分使用所有核心,兩個編譯器都使用細粒度並行化來拆解工作。

並行化的單位是功能,每個功能都可以在不同的核心上單獨編譯。這就是所謂的細粒度,實際上,我們需要將這些功能分批處理成更大的功能組。這些批次會被派送到不同的核心裡。

...然後通過隱式快取完全跳過所有工作(未來的任務)

目前,每次重新載入頁面時都會重做解碼和編譯。但是如果你有相同的 .wasm 檔案,它編譯後都是一樣的機器程式碼。

這意味著,很多時候這些工作都可以跳過。這些也是未來我們要做的。我們將在第一頁載入時進行解碼和編譯,然後將生成的機器碼快取在 HTTP 快取中。之後當你再次請求這個 URL 的時候,它會拉取預編譯的機器程式碼。

這就能讓後續載入頁面的載入時間消失了。

Timeline showing all work disappearing with caching.

這項功能已經有了基礎構建。我們在 Firefox 58 版本中快取了這樣的 JavaScript 位元組程式碼。我們只需擴充套件這種支援來快取 .wasm 檔案的機器程式碼。

關於 Lin Clark

Lin 是 Mozilla Developer Relations 團隊的工程師。她致力於 JavaScript、WebAssembly、Rust 和 Servo,還會繪製程式碼漫畫。

Lin Clark 的更多文章...

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章