JavaScript 引擎V8 Lite Mode 改造V8
去年年底,V8 團隊啟動了一個名為V8 Lite 的項目,旨在大幅降低V8 的內存使用率。最開始,團隊準備把V8 Lite 作為V8 的獨立模式,專門用於低內存的移動設備與嵌入式設備,因為這些設備更關注的是減少內存使用而不是執行速度。
在這個項目研發的過程中,開發團隊發現專門為這個Lite 模式所做的內存優化其實也可以遷移到原來的 V8 上,直接兩開花。V8 團隊近日發表了一個文章,就詳細分享了在構建V8 Lite 的過程中將一些關鍵的優化部分帶到現有V8 上的過程,以及在實際工作負載中對V8 性能表現的影響。下邊簡要介紹一下。
Lite Mode
分析了V8 如何使用內存以及哪些對像類型占V8 堆大小的比例很大之後,V8 團隊發現,V8 堆的很大一部分專門用於對JavaScript 執行來說不必要的對象,比如用於優化JavaScript 執行和處理異常情況。具體來說比如優化代碼;用於確定如何優化代碼的類型反饋;用於C++ 和JavaScript 對象之間綁定的冗餘元數據等。
所以團隊從這一點入手,想通過大幅減少這些可選對象的內存分配來提高內存使用。同時團隊提出了V8 的Lite Mode。
通過配置現有的V8 設置可以直接應用一些Lite Mode 的優化,例如禁用V8 的TurboFan 優化編譯器,但是現有V8 想支持其它Lite Mode 優化則需要更多的考慮。
比如在Ignition解釋器中執行代碼時,V8收集有關傳遞給各種操作(例如,+
和o.foo
)的操作數類型的反饋,以便為以後的優化定制這些類型。此信息存儲在反饋向量中,這些向量佔V8堆內存使用量的很大一部分。Lite Mode不優化代碼,所以可以直接不去分配這些反饋向量,但是V8的內聯緩存基礎結構的解釋器期望反饋向量可用,因此需要相當多的重構才能支持Lite Mode這種無反饋執行。
Lite Mode 在V8 v7.3 中推出,與V8 v7.1 相比,通過禁用代碼優化、不分配反饋向量並執行很少執行的字節碼老化,典型網頁堆大小減少22%。
同時在這個過程中,團隊還發現,可以通過使V8 更加“Lazy”來實現Lite Mode 的大部分內存節省,而不會影響性能。
Lazy feedback allocation
完全禁用反饋向量分配不僅會阻止V8 的TurboFan 編譯器優化代碼,還會阻止V8 執行常見操作的內聯緩存,例如Ignition 解釋器中的對象屬性加載。因此,這樣做會導致V8 的執行時間顯著回退,頁面加載時間減少12%,典型交互式網頁方案中V8 使用的CPU 時間增加120%。
為了在沒有這些回退的情況下將大部分內存節省帶到常規V8 中,開發團隊設計了在函數執行了一定量的字節碼(目前為1KB)之後,延遲分配反饋向量。由於大多數函數不經常執行,因此在大多數情況下避免使用反饋向量分配,但會在需要的地方快速分配它們以避免性能回退,並仍然允許優化代碼。
使用這種方法,會產生另一個問題。反饋向量會形成樹結構,內部函數的反饋向量被保留為外部函數的反饋向量中的條目。這是必要的,以便新創建的函數閉包接收與為同一函數創建的所有其它閉包相同的反饋向量數組。在延遲分配反饋向量的情況下,無法使用反饋向量來形成這棵樹,因為無法保證外部函數會在內部函數分配其反饋向量之前就對其進行分配。
如下圖,為了解決這個問題,開發團隊創建了一個新的ClosureFeedbackCellArray 來維護這個樹,當一個函數的ClosureFeedbackCellArray 變為 Hot 時將其與一個完整的FeedbackVector 交換掉。
實驗顯示桌面延遲反饋沒有出現性能回退,而在移動端,由於垃圾回收減少,性能有所提升,因此V8 所有版本中都啟用了延遲反饋分配。
Lazy source positions
從JavaScript 編譯字節碼時,會生成源位置表,將字節碼序列綁定到JavaScript 源代碼中的字符位置。但是,僅在表示異常或執行開發人員任務(如調試)時才需要此信息,因此很少使用。
為了避免這種浪費,現在編譯字節碼而不收集源位置(假設沒有附加調試器或分析器)。僅在實際生成堆棧跟踪時收集源位置,例如在調用Error.stack 或將異常的堆棧跟踪打印到控制台時。這需要一些成本,因為生成源位置需要重新分析和編譯函數,但是大多數網站不會在生產中對堆棧跟踪進行符號化,因此不會看到什麼性能影響。
Bytecode flushing
從JavaScript 源編譯的字節碼,包括相關的元數據佔用了大量的V8 堆空間,通常約為15%。但是有許多函數只在初始化期間執行,或者在編譯後很少使用。所以,V8 針對最近沒有執行的情況,添加了在垃圾回收期間支持從函數中刷新已編譯字節碼的功能。
具體機制是,跟踪函數字節碼的age,每次主要(mark-compact)垃圾回收age 都遞增,並在執行函數時將其重置為零。任何超過老化閾值的字節碼都可以被下一次垃圾回收回收,如果它被回收然後再次執行,則會重新編譯。
這麼設計的難點在於確保字節碼僅在不再需要的情況下才可以被刷新。例如,如果函數A 調用另一個長時間運行的函數B,函數A 可能會在它仍然在堆棧上時老化。這時候我們並不想刷新函數A 的字節碼,即使它達到了老化閾值,因為還需要在長時間運行的函數B 返回時返回它。
解決這個問題的方法是,當字節碼達到其老化閾值時,將字節碼視為弱保持狀態(weakly held),但是在堆棧或其它地方對字節碼的任何引用將強烈保持(strongly held ),並且只在沒有強鏈接的情況下才可以刷新代碼。
除了刷新字節碼,同時還刷新與這些刷新函數相關的反饋向量。但是,無法在與字節碼相同的GC 週期內刷新反饋向量,因為它們不會被同一對象保留。字節碼由本地上下文獨立的SharedFunctionInfo 保留,而反饋向量由本地上下文相關的 JSFunction 保留。因此,會在隨後的GC 循環中刷新反饋向量。
Additional optimizations
此外開發團隊還通過減小FunctionTemplateInfo 對象的大小和對 TurboFan 優化代碼進行去優化等方面的優化減少了內存使用。
Results
目前已經在V8 的最新七個版本中發布了上述優化,通常它們會先應用在Lite Mode 中,然後再進入V8 的默認配置。
經過測試,在一系列典型網站上將V8 堆大小平均減少了18%,相當於低端AndroidGo 移動設備平均減少1.5 MB。
通過禁用功能優化,Lite Mode 可以以一定的開銷進一步為JavaScript 執行吞吐量提供內存節省。平均而言,Lite Mode 可節省22% 的內存,有些頁面可節省高達32% 的內存。這相當於AndroidGo 設備上V8 堆大小減少了1.8 MB。
具體分析每一種優化技術帶來的影響,結果如下:
完整優化介紹查看原博客: