更輕量級的 V8 引擎[每日前端夜話0xC8]
- 2019 年 10 月 11 日
- 筆記
作者:Mythri Alle, Dan Elphick, and Ross McIlroy
翻譯:瘋狂的技術宅
來源:https://v8.dev/blog/v8-lite

在 2018 年末,為了大幅減少 V8 的記憶體使用量,我們啟動了一個名為 V8 Lite 的項目。該項目最初被設想為 V8 的一個獨立的 精簡模式(Lite mode),專門針對低記憶體移動設備或嵌入式用例,這些用例更關心的是減少記憶體的使用而不是吞吐量的執行速度。但是在進行這項工作的過程中,我們意識到為Lite 模式所做的許多記憶體優化都可以轉移到常規 V8 中,從而使 V8 的所有用戶受益。
本文重點介紹了我們開發的一些關鍵優化以及它們在實際工作負載中對記憶體所做的優化。
注意:如果您不喜歡閱讀文章,請欣賞下面的影片!
Ross McIlroy在BlinkOn 10上發表的 「V8 Lite – 減少 JavaScript 記憶體」:
Lite 模式
為了優化 V8 的記憶體使用,我們首先需要了解 V8 如何使用記憶體以及哪些對象類型在 V8 堆中佔了很大的比例。我們用了 V8 的記憶體可視化【https://v8.dev/blog/optimizing-v8-memory#memory-visualization】工具來跟蹤許多典型網頁的堆內容的構成。

載入印度時報時,不同對象類型使用的 V8 堆的百分比
為此,我們確定了對 JavaScript 執行並不是必不可少的對象在 V8 堆中佔了很大一部分 ,但是這些對象被用於優化 JavaScript 執行,並處理特殊情況。例如:優化的程式碼;類型回饋,用於確定如何優化程式碼;用於在 C++ 和 JavaScript 對象之間進行綁定的冗餘元數據;僅在特殊情況下才需要元數據,如堆棧跟蹤符號;還有在頁面載入期間僅執行幾次的函數的位元組碼。
結果,我們開始在 V8 的 精簡模式 上進行工作,該模式通過大幅減少這些可選對象的分配來權衡 JavaScript 執行的速度與節省的記憶體。

通過配置現有的 V8 設置,可以對精簡模式進行許多更改,例如禁用 V8 的 TurboFan 優化編譯器。但是其他的優化還需要對 V8 進行更多的修改。
特別是,由於我們決定在精簡模式下無法優化程式碼,因此可以避免收集優化編譯器所需的類型回饋。在 Ignition 解釋器中執行程式碼時,V8 會收集有關傳遞給各種操作的操作數類型(例如,+
或 o.foo
)的回饋,以便針對這些類型調整以後的優化。這些資訊存儲在回饋向量中,這些向量在 V8 堆記憶體中使用了很大的一部分。精簡模式可以避免分配這些回饋向量,但是 V8 的解釋器和部分內聯快取基礎結構卻希望回饋向量可用,因此還需要進行大量重構才能支援這種無回饋執行。
在 V8 的 v7.3 版本中啟動的精簡模式與 v7.1 相比,通過禁用程式碼優化,不分配回饋矢量以及執行很少執行的位元組碼老化(如下所述),使典型的網頁堆大小減少了 22%。對於那些明顯想要權衡性能以提高記憶體使用率的程式而言,這是一個非常不錯的結果。但是在執行此項工作的過程中,我們意識到通過使 V8 變得更懶惰,可以實現節省精簡模式的大部分記憶體,而不會影響性能。
惰性回饋分配
完全禁用回饋向量分配,不僅會阻止 V8 的 TurboFan 編譯器對程式碼進行優化,而且還會阻止 V8 執行常見操作(例如對象)的 inline caching 【https://mathiasbynens.be/notes/shapes-ics#ics】屬性在 Ignition 解釋器中的載入。所以這樣做會大大降低 V8 的執行時間,在典型的互動式網頁方案中,頁面載入時間減少了 12%,而 V8 使用的 CPU 時間增加了120%。
為了在不進行這些回歸的情況下將節省的大部分記憶體用於常規 V8,我們轉而採用了另一種方法,在該函數執行了一定數量的位元組碼(當前為1KB)之後,開始惰性分配回饋向量。由於大多數函數並不是要經常執行,因此在大多數情況下,我們避免分配回饋矢量,而是在需要的地方快速分配它們,以避免性能下降,並且仍然可以對程式碼進行優化。
這種方法的另一個複雜性與以下事實有關:回饋向量形成一棵樹,內部函數的回饋向量被保留為外部函數的回饋向量中的條目。這是非常必要的,這樣可以使新創建的函數閉包與為同一函數創建的所有閉包一樣,接收相同的回饋矢量數組。在惰性分配回饋向量的情況下,我們無法用回饋向量來形成這棵樹,因為無法保證外部函數會在內部函數分配其回饋向量之前就對其進行分配。為了解決這個問題,我們創建了一個新的 ClosureFeedbackCellArray
來維護這棵樹,然後在函數變熱時用一個完整的 FeedbackVector
換出一個函數的 ClosureFeedbackCellArray
。

惰性回饋分配前後的回饋矢量樹
我們實驗和現場測試結果表明,在台式機上的惰性回饋沒有出現性能下降的趨勢,而在移動平台上,由於減少了垃圾收集,實際上在低端設備上性能有所提高。因此我們在所有 V8 版本中都啟用了惰性回饋分配,其中包括精簡模式,與我們原始的無回饋分配方法相比,記憶體模式略有退步,但是實際性能卻得到了很大的提高。
惰性源位置
從 JavaScript 編譯位元組碼時,會生成把位元組碼序列與 JavaScript 源碼中的字元位置相關聯的源位置表。但是僅在符號化異常或執行開發人員任務(例如調試)時才需要此資訊,因此很少使用。
為了避免這種浪費,現在編譯位元組碼時不收集源位置(假設未連接調試器或分析器),僅在實際生成堆棧跟蹤時(例如,在調用 Error.stack
或將異常的棧跟蹤列印到控制台時)才收集源。這確實需要付出一些代價,因為生成源位置需要重新解析和編譯函數,但是大多數網站並未在生產中使用棧跟蹤符號,所以看不到什麼能夠觀察到的性能影響。
我們必須解決的一個問題是需要可重複的位元組碼生成,而這是以前無法保證的。如果 V8 在收集源位置時與原始程式碼生成不同的位元組碼,則源位置不對齊,並且堆棧跟蹤可能指向源程式碼中的錯誤位置。
在某些情況下,由於在函數在先急速解析再延遲編譯時丟失了一些解析資訊,V8 可能會根據某個函數是急速還是延遲編譯【https://v8.dev/blog/preparser#skipping-inner-functions】來生成不同的位元組碼。這些不匹配大多是良性的,例如,忘記了變數是不可變的事實,因此無法對其進行優化。但是,這項工作發現的某些不匹配在某些情況下確實有可能導致程式碼錯誤的執行。因此,我們修復了這些不匹配問題,並添加了檢查和壓力模式,以確保函數的急速和惰性編譯始終能夠產生一致的輸出,從而使我們對 V8 解析器和預解析器的正確性和一致性更具信心。
位元組碼刷新
從 JavaScript 源碼編譯的位元組碼佔據了 V8 堆空間的很大一部分,通常大約為 15%,其中包括相關的元數據。有許多函數僅在初始化的時候執行,或者在編譯後很少被使用。
所以我們添加了對垃圾回收期間從函數中清除編譯後的位元組碼的支援,如果它們最近沒有執行過的話。為此我們要跟蹤函數位元組碼的 age,增加每個 major(mark-compact)【https://v8.dev/blog/trash-talk#major-gc】垃圾回收的 age,並在執行該函數時將其重置為零。任何超過老化閾值的位元組碼都可以在下一次垃圾回收中被收集。如果已收集了,但是稍後需要再次執行,那麼將會重新編譯它。
要確保只在不再需要位元組碼時才刷新它存在著技術難題。如果函數 A
調用另一個長期運行的函數 B
,則函數 A
可能會在其仍在堆棧中時老化。即使函數 A
達到了老化閾值我們也不希望刷新它的位元組碼,因為我們需要在長時間運行的函數 B
返回到 A
。因此當位元組碼達到函數的老化閾值時,我們會將其視為函數的弱保留,而堆棧或其他位置對它的任何引用都作為強保留。我們僅在沒有強鏈接剩餘時才刷新程式碼。
除了刷新位元組碼,我們還刷新與這些刷新函數關聯的回饋向量,但是我們無法在與位元組碼相同的 GC 周期內刷新它們,因為它們沒有被同一對象保留。位元組碼由與本機上下文無關的 SharedFunctionInfo
保留,而回饋向量則由依賴於本機上下文的 JSFunction
保留。最後我們在隨後的 GC 周期中刷新回饋向量。

經過兩個 GC 循環後,老化的函數的對象布局
其他優化
除了這些較大的項目,我們還發現並解決了一些導致效率低下的問題。
第一個是減小 FunctionTemplateInfo
對象的大小。這些對象存儲與 FunctionTemplate
有關的內部元數據,這些元數據用於使嵌入程式(例如 Chrome)提供可被調用的函數的 C++ 回調實現。通過 JavaScript 程式碼。Chrome 瀏覽器引入了許多 FunctionTemplates
以實現 DOM Web API,因此,FunctionTemplateInfo
對象對 V8 的堆大小有所貢獻。在分析 FunctionTemplates
的典型用法之後,我們發現在 FunctionTemplateInfo
對象上的11個欄位中,通常只有 3 個被設置為非默認值。因此我們拆分了 FunctionTemplateInfo
對象,以便將稀有欄位存儲在邊表中,該邊表僅在需要時才按需分配。
第二個優化與如何取消 TurboFan 的程式碼優化有關。由於 TurboFan 執行推測性優化,所以如果某些條件不再成立,則可能需要回退到解釋器(取消優化)。每個取消點都有一個 ID,該 ID 可以使運行時能夠確定位元組碼應該把執行返回到解釋器中的哪個位置上。以前通過優化程式碼跳轉到大型跳轉表中的特定偏移量來計算這個 ID,然後再將正確的 ID 載入到暫存器中,最後跳轉到運行時以執行反優化。這樣做的好處是,對於每個取消點,在優化程式碼中只需要一條跳轉指令。但是,取消優化跳轉表已經預先分配,並且它必須足夠大,這樣才能支援整個取消優化 id 的範圍。所以我們修改了 TurboFan,使優化程式碼中的 deopt 點在調用運行時之前可以直接載入 deopt id。這樣我們就能夠完全刪除這個大型跳轉表,但是代價是需要略微增加優化程式碼的大小。
結果
我們已經在 V8 最後七個版本中發布了上述優化。通常,它們首先以精簡模式開始,然後又被帶到 V8 的默認配置。

AndroidGo設備上一組典型網頁的 V8 堆的平均大小

與v7.1(Chrome 71)相比,V8 的 v7.8(Chrome 78)版本每種頁面的記憶體節省情況詳情
在這段時間裡,我們在一系列典型網站上將 V8 堆大小平均減少了 18%,這對應於低端 AndroidGo 移動設備,平均減少了 1.5 MB。在基準測試或實際的網頁交互中,這對 JavaScript 性能可能並沒有什麼重大影響。
精簡模式可以通過禁用函數優化來進一步節省記憶體,但會以一定的成本提高 JavaScript 執行吞吐量。平均而言,精簡模式可節省 22% 的記憶體,而某些頁面最多可節省 32%。這對應於 AndroidGo 設備上的 V8 堆大小減少了 1.8 MB。

與 v7.1(Chrome 71)相比,V8 v7.8(Chrome 78)的記憶體用量減少了
當把每個優化的影響分開來看時,很明顯,不同的頁面會從每一個優化中獲得不同比例的收益。展望未來,我們將繼續尋找潛在的優化方案,這些優化方案可以進一步減少 V8 對記憶體的使用量,同時仍然保持 JavaScript 驚人的執行速度。
