作者:Lin Clark
編譯:鬍子大哈
翻譯原文:huziketang.com/blog/posts/…
英文原文:What makes WebAssembly fast?
轉載請註明出處,保留原文連結以及作者資訊
本文作者:Lin Clark
英文原文:What makes WebAssembly fast?
本文是關於 WebAssembly 系列的第五篇文章(本系列共六篇文章)。如果你沒有讀先前文章的話,建議先讀這裡。如果對 WebAssembly 沒概念,建議先讀這裡(中文文章)。
上一篇文章中,我介紹瞭如何編寫 WebAssembly 程式,也表達了我希望看到更多的開發者在自己的工程中同時使用 WebAssembly 和 JavaScript 的期許。
開發者們不必糾結於到底選擇 WebAssembly 還是 JavaScript,已經有了 JavaScript 工程的開發者們,希望能把部分 JavaScript 替換成 WebAssembly 來嘗試使用。
例如,正在開發 React 程式的團隊可以把調節器程式碼(即虛擬 DOM)替換成 WebAssembly 的版本。而對於你的 web 應用的使用者來說,他們就跟以前一樣使用,不會發生任何變化,同時他們還能享受到 WebAssembly 所帶來的好處——快。
而開發者們選擇替換為 WebAssembly 的原因正是因為 WebAssembly 比較快。那麼為什麼它執行的快呢?我們來一起了解一下。
當前的 JavaScript 效能如何?
在我們瞭解 JavaScript 和 WebAssembly 的效能區別之前,需要先理解 JS 引擎的工作原理。
下面這張圖片介紹了效能使用的大概分佈情況。
JS 引擎在圖中各個部分所花的時間取決於頁面所用的 JavaScript 程式碼。圖表中的比例並不代表真實情況下的確切比例情況。
圖中的每一個顏色條都代表了不同的任務:
- Parsing——表示把原始碼變成直譯器可以執行的程式碼所花的時間;
- Compiling + optimizing——表示基線編譯器和優化編譯器花的時間。一些優化編譯器的工作並不在主執行緒執行,不包含在這裡。
- Re-optimizing——當 JIT 發現優化假設錯誤,丟棄優化程式碼所花的時間。包括重優化的時間、拋棄並返回到基線編譯器的時間。
- Execution——執行程式碼的時間
- Garbage collection——垃圾回收,清理記憶體的時間
這裡注意:這些任務並不是離散執行的,或者按固定順序依次執行的。而是交叉執行,比如正在進行解析過程時,其他一些程式碼正在執行,而另一些正在編譯。
這樣的交叉執行給早期 JavaScript 帶來了很大的效率提升,早期的 JavaScript 執行類似於下圖,各個過程順序進行:
早期時,JavaScript 只有直譯器,執行起來非常慢。當引入了 JIT 後,大大提升了執行效率,縮短了執行時間。
JIT 所付出的開銷是對程式碼的監視和編譯時間。JavaScript 開發者可以像以前那樣開發 JavaScript 程式,而同樣的程式,解析和編譯的時間也大大縮短。這就使得開發者們更加傾向於開發更復雜的 JavaScript 應用。
同時,這也說明了執行效率上還有很大的提升空間。
WebAssembly 對比
下面是 WebAssembly 和典型的 web 應用的近似對比圖:
各種瀏覽器處理上圖中不同的過程,有著細微的差別,我用 SpiderMonkey 作為模型來講解不同的階段:
檔案獲取
這一步並沒有顯示在圖表中,但是這看似簡單地從伺服器獲取檔案這個步驟,卻會花費很長時間。
WebAssembly 比 JavaScript 的壓縮率更高,所以檔案獲取也更快。即便通過壓縮演算法可以顯著地減小 JavaScript 的包大小,但是壓縮後的 WebAssembly 的二進位制程式碼依然更小。
這就是說在伺服器和客戶端之間傳輸檔案更快,尤其在網路不好的情況下。
解析
當到達瀏覽器時,JavaScript 原始碼就被解析成了抽象語法樹。
瀏覽器採用懶載入的方式進行,只解析真正需要的部分,而對於瀏覽器暫時不需要的函式只保留它的樁。
解析過後 AST (抽象語法樹)就變成了中間程式碼(叫做位元組碼),提供給 JS 引擎編譯。
而 WebAssembly 則不需要這種轉換,因為它本身就是中間程式碼。它要做的只是解碼並且檢查確認程式碼沒有錯誤就可以了。
編譯和優化
上一篇關於 JIT 的文章中,我有介紹過,JavaScript 是在程式碼的執行階段編譯的。因為它是弱型別語言,當變數型別發生變化時,同樣的程式碼會被編譯成不同版本。
不同瀏覽器處理 WebAssembly 的編譯過程也不同,有些瀏覽器只對 WebAssembly 做基線編譯,而另一些瀏覽器用 JIT 來編譯。
不論哪種方式,WebAssembly 都更貼近機器碼,所以它更快,使它更快的原因有幾個:
- 在編譯優化程式碼之前,它不需要提前執行程式碼以知道變數都是什麼型別。
- 編譯器不需要對同樣的程式碼做不同版本的編譯。
- 很多優化在 LLVM 階段就已經做完了,所以在編譯和優化的時候沒有太多的優化需要做。
重優化
有些情況下,JIT 會反覆地進行“拋棄優化程式碼<->重優化”過程。
當 JIT 在優化假設階段做的假設,執行階段發現是不正確的時候,就會發生這種情況。比如當迴圈中發現本次迴圈所使用的變數型別和上次迴圈的型別不一樣,或者原型鏈中插入了新的函式,都會使 JIT 拋棄已優化的程式碼。
反優化過程有兩部分開銷。第一,需要花時間丟掉已優化的程式碼並且回到基線版本。第二,如果函式依舊頻繁被呼叫,JIT 可能會再次把它傳送到優化編譯器,又做一次優化編譯,這是在做無用功。
在 WebAssembly 中,型別都是確定了的,所以 JIT 不需要根據變數的型別做優化假設。也就是說 WebAssembly 沒有重優化階段。
執行
自己也可以寫出執行效率很高的 JavaScript 程式碼。你需要了解 JIT 的優化機制,例如你要知道什麼樣的程式碼編譯器會對其進行特殊處理(JIT 文章裡面有提到過)。
然而大多數的開發者是不知道 JIT 內部的實現機制的。即使開發者知道 JIT 的內部機制,也很難寫出符合 JIT 標準的程式碼,因為人們通常為了程式碼可讀性更好而使用的編碼模式,恰恰不合適編譯器對程式碼的優化。
加之 JIT 會針對不同的瀏覽器做不同的優化,所以對於一個瀏覽器優化的比較好,很可能在另外一個瀏覽器上執行效率就比較差。
正是因為這樣,執行 WebAssembly 通常會比較快,很多 JIT 為 JavaScript 所做的優化在 WebAssembly 並不需要。另外,WebAssembly 就是為了編譯器而設計的,開發人員不直接對其進行程式設計,這樣就使得 WebAssembly 專注於提供更加理想的指令(執行效率更高的指令)給機器就好了。
執行效率方面,不同的程式碼功能有不同的效果,一般來講執行效率會提高 10% - 800%。
垃圾回收
JavaScript 中,開發者不需要手動清理記憶體中不用的變數。JS 引擎會自動地做這件事情,這個過程叫做垃圾回收。
可是,當你想要實現效能可控,垃圾回收可能就是個問題了。垃圾回收器會自動開始,這是不受你控制的,所以很有可能它會在一個不合適的時機啟動。目前的大多數瀏覽器已經能給垃圾回收安排一個合理的啟動時間,不過這還是會增加程式碼執行的開銷。
目前為止,WebAssembly 不支援垃圾回收。記憶體操作都是手動控制的(像 C、C++一樣)。這對於開發者來講確實增加了些開發成本,不過這也使程式碼的執行效率更高。
總結
WebAssembly 比 JavaScript 執行更快是因為:
- 檔案抓取階段,WebAssembly 比 JavaScript 抓取檔案更快。即使 JavaScript 進行了壓縮,WebAssembly 檔案的體積也比 JavaScript 更小;
- 解析階段,WebAssembly 的解碼時間比 JavaScript 的解析時間更短;
- 編譯和優化階段,WebAssembly 更具優勢,因為 WebAssembly 的程式碼更接近機器碼,而 JavaScript 要先通過伺服器端進行程式碼優化。
- 重優化階段,WebAssembly 不會發生重優化現象。而 JS 引擎的優化假設則可能會發生“拋棄優化程式碼<->重優化”現象。
- 執行階段,WebAssembly 更快是因為開發人員不需要懂太多的編譯器技巧,而這在 JavaScript 中是需要的。WebAssembly 程式碼也更適合生成機器執行效率更高的指令。
- 垃圾回收階段,WebAssembly 垃圾回收都是手動控制的,效率比自動回收更高。
這就是為什麼在大多數情況下,同一個任務 WebAssembly 比 JavaScript 表現更好的原因。
但是,還有一些情況 WebAssembly 表現的會不如預期;同時 WebAssembly 的未來也會朝著使 WebAssembly 執行效率更高的方向發展。這些我會在下一篇文章《WebAssembly 系列(六)WebAssembly 的現在與未來》中介紹。
我最近正在寫一本《React.js 小書》,對 React.js 感興趣的童鞋,歡迎指點。