閱讀405 返回首頁    go 技術社區[雲棲]


V8 Ignition:JS 引擎與字節碼的不解之緣

ARE WE FAST YET? 網站測試並展示了數個 JavaScript 引擎的性能數據,是各家 JS 引擎性能的比武場:

1.png

我們看到在這個比武場上,最近 Chrome 出現了多個新條目,其中很多條目都是關於 v8 的 Ignition 新架構的組合,他們是 v8 引擎最近推出的 JS 字節碼解釋器。

縱覽各個 JS 引擎的實現,我們發現基於字節碼的實現是主流。例如蘋果公司的 JavaScriptCore (JSC) 引擎,2008 年時他們引入了 SquirrelFish(市場名 Nitro),實現了一個字節碼寄存器機(Register Machine)。再如 Mozilla 公司的 SpiderMonkey,他們使用字節碼的曆史更久,可以追溯到 1998 年的 Netscape 4(見 https://dxr.mozilla.org/classic/source/js/src/jsemit.c ),SpiderMonkey 實現的是堆棧機(Stack Machine)。微軟的 Chakra 也使用了字節碼,他們實現的是寄存器機(Register Machine)。而 v8 之前的做法是比較“脫俗”的,他們跳過了字節碼這一層,直接把 JS 編譯成機器碼。而在剛剛過去的五一假日前夕,v8 5.9 發布了,其中的 Ignition 字節碼解釋器將默認啟動 : V8 Release 5.9 。v8 自此回到了字節碼的懷抱。

這讓筆者不禁懷念起 2007 年 Ruby 1.9 的發布。當時 Ruby 1.9 也是第一次引入了字節碼,名為 YARV,由笹田耕一領導主導開發完成。當時,Ruby 還在使用鬆本行弘的初級的解釋器實現,亦即,解釋器每次遍曆代碼的抽象語法樹(AST)來進行 Ruby 代碼的解釋執行。而 YARV 則把抽象語法樹(AST)先編譯成字節碼,然後再運行。引入字節碼之後,Ruby 的性能得到了顯著的提升。

而這次 V8 引入字節碼卻是向著相反的方向後退。因為之前 v8 選擇了直接將 JS 代碼編譯到機器代碼執行,機器碼的執行性能已經非常之高,而這次引入字節碼則是選擇編譯 JS 代碼到一個中間態的字節碼,執行時是解釋執行,性能是低於機器代碼的。最終的性能測試勢必會降低,而不是提高。那麼 V8 為什麼要做這樣一個退步的選擇呢?為 V8 引入字節碼的動機又是什麼呢?筆者總結下來有三條:

  • (主要動機)減輕機器碼占用的內存空間,即犧牲時間換空間
  • 提高代碼的啟動速度
  • 對 v8 的代碼進行重構,降低 v8 的代碼複雜度

故事得從 Chrome 的一個 bug 說起: https://crbug.com/593477 。Bug 的報告人發現,當在 Chrome 51 (canary) 瀏覽器下加載、退出、重新加載 facebook 多次,並打開 about:tracing 裏的各項監控開關,可以發現第一次加載時 v8.CompileScript 花費了 165 ms,再次加載加入 V8.ParseLazy 居然依然花費了 376 ms。按說如果 Facebook 網站的 js 腳本沒有變,Chrome 的緩存功能應該緩存了對 js 腳本的解析結果,不該花費這麼久。這是為什麼呢?

這就是之前 v8 將 JS 代碼編譯成機器碼所帶來的問題。因為機器碼占空間很大,v8 沒有辦法把 Facebook 的所有 js 代碼編譯成機器碼緩存下來,因為這樣不僅緩存占用的內存、磁盤空間很大,而且退出 Chrome 再打開時序列化、反序列化緩存所花費的時間也很長,時間、空間成本都接受不了。

所以 v8 退而求其次,隻編譯最外層的 js 代碼,也就是下圖這個例子裏麵綠色的部分。那麼內部的代碼(如下圖中的黃色、紅色的部分)是什麼時候編譯的呢?v8 推遲到第一次被調用的時候再編譯。這時間上的推移還導致另外一個短板,就是代碼必須被解析多次——綠色的代碼一次、黃色的代碼再解析一次(當 new Person 被調用)、紅色的代碼再解析一次(當 doWork() 被調用)。因此,如果你的 js 代碼的閉包套了 n 層,那麼最終他們至少會被 v8 解析 n 次。

2.png

Facebook 的網站之所以收到這個設計帶來的負麵的性能影響,就是因為他們的前段工程流程中最後把各個獨立的 module 編譯成了一個單獨的文件,其中用到了很多閉包,如:

3.png

如此一來 Chrome 的緩存作用就隻能作用在最外層的 __d() 代碼上,而內部的真正的邏輯根本沒有被緩存。

剛才提到了機器碼占空間大的一個壞處,就是不能一次性編譯全部的代碼。機器碼占空間大還有另外一個壞處,就是一些隻運行一次的代碼浪費了寶貴的內存資源。正如上麵 Facebook 中的 __d() 係列函數,他們的作用可能隻是注冊、初始化各個模塊組件,而一旦初始化完成便不會再執行。但由於機器碼占空間大,這些隻執行一次的代碼也會在內存中長期存在、長期占用空間。正如下圖所示,一般情況下大約 30% 的 V8 堆空間都用來存儲未優化的機器碼。

4.png

而引入字節碼之後,占空間的問題就可以得到緩解。通過恰當地設計字節碼的編碼方式,字節碼可以做到比機器碼緊湊很多。V8 引入 Ignition 字節碼後,代碼的內存占用確實降低了,如下圖所示。

5.png

通過對十大流行手機端網站的測試,可以發現他們的內存占用顯著下降。

6.png

這便是 v8 引入字節碼的主要動機。而這樣實現之後其實順便又帶來了兩個好處,筆者認為可以視作 v8 引入字節碼的次要動機,亦即:更快的啟動速度和更好的 v8 代碼重構。

在啟動速度方麵,如今內存占用過大的問題消除了,就可以提前編譯所有代碼了。因為前端工程為了節省網絡流量,其最終 JS 產品往往不會分發無用的代碼,所以可以期望全部提前編譯 JS 代碼不會因為編譯了過多代碼而浪費資源。v8 對於 Facebook 這樣的網站就可以選擇全部提前編譯 JS 代碼到字節碼,並把字節碼緩存下來,如此 Facebook 第二次打開的時候啟動速度就變快了。下圖是舊的 v8 的執行時間的統計數據,其中 33% 的解析、編譯 JS 腳本的時間在新架構中就可以被縮短。

7.png

v8 自身的重構方麵,有了字節碼,v8 可以朝著簡化的架構方向發展,消除 Cranshaft 這個舊的編譯器,並讓新的 Turbofan 直接從字節碼來優化代碼,並當需要進行反優化的時候直接反優化到字節碼,而不需要再考慮 JS 源代碼。最終達到如下圖所示的架構。

8.png

其實,Ignition + TurboFan 的組合,就是字節碼解釋器 + JIT 編譯器的黃金組合。這一黃金組合在很多 JS 引擎中都有所使用,例如微軟的 Chakra,它首先解釋執行字節碼,然後觀察執行情況,如果發現熱點代碼,那麼後台的 JIT 就把字節碼編譯成高效代碼,之後便隻執行高效代碼而不再解釋執行字節碼。蘋果公司的 SquirrelFish Extreme 也引入了 JIT。SpiderMonkey 更是如此,所有 JS 代碼最初都是被解釋器解釋執行的,解釋器同時收集執行信息,當它發現代碼變熱了之後,JaegerMonkey、IonMonkey 等 JIT 便登場,來編譯生成高效的機器碼。

回顧曆史,很多 JS 引擎都是采用了字節碼這一腳本語言實現技術的,而 v8 一枝獨秀,走“純機器碼”路線,其實過於激進了:雖然執行性能上可以登峰造極,但卻帶來了內存占用過大的問題。這次引入字節碼實則是做了工程上的恰當取舍,將損失掉的內存找回來,更加符合如今移動和嵌入式設備為主的應用場景;以時間換空間,讓 v8 能更好的服務於低內存的設備。如今 V8 也回到了字節碼的懷抱,不禁令人感歎 JS 引擎與字節碼真是有著不解之緣!

最後更新:2017-05-04 12:01:17

  上一篇:go A Critique of ANSI SQL Isolation Levels 論文翻譯
  下一篇:go MSSQL-應用案例-SQL Server 2016基於內存優化表的列存儲索引分析Web Access Log