更輕量的 V8
2018 年底,我們啟動了一個名為 V8 Lite 的專案,目的是顯著減少 V8 的記憶體使用量。起初,這項專案被構想為 V8 的一個獨立 輕量模式,專門針對低記憶體的行動裝置或注重記憶體使用而非執行速度的嵌入式案例。然而,在此期間,我們意識到許多為這個 輕量模式 開發的記憶體優化技術可以應用到常規的 V8,從而讓所有 V8 的使用者受益。
在本文中,我們著重介紹一些我們開發的關鍵優化技術以及它們在真實工作負載中的記憶體節省效果。
注意: 如果你更喜歡觀看演講而非閱讀文章,那麼請欣賞下方的影片!如果不是,請跳過影片繼續閱讀。
輕量模式
為了優化 V8 的記憶體使用,我們首先需要了解 V8 如何使用記憶體,以及哪些物件類型佔據了 V8 堆大小中的大部分。我們使用了 V8 的 記憶體可視化 工具,對多個典型的網頁進行堆組成的追蹤分析。
通過這些分析,我們發現 V8 堆內存中有大量是為了優化 JavaScript 執行或處理特殊情況而分配的非必需物件。例如:
優化的程式碼;用於決定如何優化程式碼的型別回饋;C++ 和 JavaScript 物件之間的冗餘中繼資料;在例外情況(例如堆疊追蹤符號化)下才需要的中繼資料;以及在頁面載入期間僅執行少次的函式的位元碼。
因此,我們開始為 V8 開發一種 輕量模式,這種模式透過大幅縮減這些可選物件的分配,來在 JavaScript 執行速度與記憶體節省之間進行權衡。
部分 輕量模式 的變更可以藉由配置現有的 V8 設定來實現,例如關閉 V8 的 TurboFan 優化編譯器。然而,有一些變更需要對 V8 進行更複雜的修改。
特別是,我們決定,由於 輕量模式 不進行程式碼優化,我們可以避免收集優化編譯器所需的型別回饋。在使用 Ignition 解釋器執行程式碼時,V8 會收集有關傳遞給各種操作的運算元型別(例如 +
或 o.foo
)的回饋,以便稍後針對這些型別進行最佳化。這些資訊被存儲在 回饋向量 中,而這些回饋向量佔據了 V8 堆記憶體使用的很大一部分。輕量模式 可以避免分配這些回饋向量,但解釋器和 V8 的部分內聯快取基礎設施預期回饋向量是可用的,因此需要大量的重構以實現無回饋的執行。
輕量模式 在 V8 v7.3 中推出,通過停用程式碼優化、不分配回饋向量以及對甚少執行的位元碼進行老化(如下所述),與 V8 v7.1 相比,典型網頁堆大小減少了 22%。這對於那些明確希望權衡性能以換取更佳記憶體使用的應用程式來說,是個不錯的成績。然而,在進行這項工作過程中,我們發現,我們可以通過讓 V8 更具惰性來在不影響性能的情況下實現大部分的記憶體節省。
完全禁用反饋向量分配不僅阻止了 V8 的 TurboFan 編譯器進行代碼優化,還阻止了 V8 對常見操作(例如在 Ignition 解釋器中進行對象屬性加載)執行內聯緩存。因此,這導致 V8 執行時間顯著退化,頁面加載時間降低了 12%,並且在典型的交互式網頁場景中使用的 V8 CPU 時間增加了 120%。
為了將其中的大部分節省帶到常規 V8 而未引起這些退化,我們改為採用一種方法,即在函數執行了一定量的位元碼(目前為 1KB)後延遲分配反饋向量。由於大多數函數執行次數不頻繁,我們在大多數情況下避免了反饋向量分配,但在需要時快速分配它們以避免性能退化,並且仍然允許代碼進行優化。
這種方法的一個額外複雜性與反饋向量形成樹的事實有關,內部函數的反饋向量作為條目存儲在其外部函數的反饋向量中。這是必要的,這樣新創建的函數閉包可以與同一函數創建的所有其他閉包接收相同的反饋向量數組。通過延遲分配反饋向量,我們無法使用反饋向量形成這棵樹,因為無法保證外部函數在內部函數執行時已分配其反饋向量。為了解決這個問題,我們創建了一個新的ClosureFeedbackCellArray
來維護這棵樹,然後在它變得熱門時將函數的ClosureFeedbackCellArray
替換為完整的FeedbackVector
。
我們的實驗室測試和實地性能數據顯示,桌面設備上對於延遲反饋分配並無性能退化,而在移動平台上,低端設備由於垃圾回收減少實際上還出現了性能改進。因此,我們在所有 V8 構建中啟用了延遲反饋分配,包括輕量模式,其中相比我們原本的無反饋分配方法,稍微增加的內存回退已被實際性能改進所充分補償。
延遲源位置分配
當通過 JavaScript 編譯位元碼時,會生成源位置表,將位元碼序列與 JavaScript 源代碼中的字符位置關聯。然而,此信息僅在符號化異常或執行調試等開發者任務時需要,因此很少被使用。
為了避免這種浪費,我們現在在編譯位元碼時不收集源位置(假設未附加調試器或分析器)。僅在實際生成堆棧跟蹤時收集源位置,例如調用Error.stack
或將異常的堆棧跟蹤打印到控制台時。這確實會帶來一定的成本,因為生成源位置需要重新解析和編譯函數,不過大多數網站在生產環境中不會符號化堆棧跟蹤,因此看不到任何可察覺的性能影響。
我們在此工作中需要解決的一個問題是要求可重複的位元碼生成,這先前未有保證。如果 V8 在收集源位置時生成的位元碼與原始代碼不同,那麼源位置將不對齊,堆棧跟蹤可能會指向源代碼中的錯誤位置。
在某些情況下,V8 可以依據函數是否主動或延遲編譯生成不同的位元碼,因為一些解析器信息在函數的初步主動解析與後續延遲編譯之間會丟失。這些不匹配大多是惰性問題,例如失去變量不可變的跟蹤因而無法優化。但此項工作揭示的一些不匹配在某些情況下確實可能導致錯誤的代碼執行。因此,我們修復了這些不匹配,並添加了檢查及壓力模式以確保函數的主動和延遲編譯始終生成一致的輸出,這使我們對 V8 的解析器和預解析器的正確性和一致性更具信心。
位元碼清理
從 JavaScript 源代碼編譯而來的位元碼佔用了 V8 堆空間的一大部分,通常約為 15%,包括相關元數據。有許多函數僅在初始化期間執行,或者在編譯後很少使用。
因此,我們添加了支持在垃圾回收期間清理久未執行的函數編譯位元碼的功能。為了實現這一功能,我們跟蹤函數位元碼的年齡,在每次主要(標記-壓縮)垃圾回收時遞增年齡,並在函數執行時將其重置為零。任何跨越老化門檻的位元碼都符合條件由下一次垃圾回收收集。如果它在被收集後再次執行,它將重新編譯。
為確保位元組碼僅在不再需要時才被清除,我們面臨了一些技術挑戰。例如,假如函數 A
呼叫了另一個執行時間較長的函數 B
,函數 A
可能在仍在堆疊上時被老化。我們不希望清除函數 A
的位元組碼,即使它達到了老化門檻,因為當執行時間較長的函數 B
返回時,我們需要重新進入函數 A
。因此,我們認為位元組碼在達到老化門檻時只是被弱引用,但如果堆疊或其他地方有任何引用,則仍被強引用。只有在沒有強引用時,我們才會清除該位元組碼。
除了清除位元組碼,我們還清除了與這些已清除函數相關的反饋向量。然而,我們不能在與位元組碼相同的 GC 週期內清除反饋向量,因為它們不是由同一物件保留的——位元組碼由與原生上下文獨立的 SharedFunctionInfo
保留,而反饋向量則由依賴於原生上下文的 JSFunction
保留。因此,我們在隨後的 GC 週期中清除反饋向量。
其他優化
除了這些大型項目,我們還發現並解決了一些效率低下的問題。
首先是減少 FunctionTemplateInfo
物件的大小。這些物件存儲 FunctionTemplate
的內部元數據,這些模板使嵌入者(如 Chrome)能夠為 JavaScript 代碼調用的函數提供 C++ 回調實現。Chrome 為了實現 DOM 網頁 API 引入了大量的 FunctionTemplates,因此 FunctionTemplateInfo
物件增加了 V8 堆的大小。通過分析 FunctionTemplates 的典型使用方式,我們發現,在 FunctionTemplateInfo
物件的十一個字段中,通常只有三個字段被設置為非默認值。因此,我們將 FunctionTemplateInfo
物件拆分,將不常用的字段存儲在一個僅在必要時按需分配的附加表中。
第二個優化與如何從 TurboFan 優化的代碼中取消優化有關。由於 TurboFan 顯示性地執行優化,如果某些條件不再成立,它可能需要回退到解釋器(取消優化)。每個取消優化點都有一個 id,這樣運行時就可以確定它應該返回到解釋器中的位元組碼的哪一部分。以前,這個 id 是通過讓優化代碼跳轉到一個大型跳轉表中的特定偏移量來計算的,該表將正確的 id 加載到一個寄存器中,然後進入運行時執行取消優化。這樣的好處是對於每個取消優化點,優化代碼只需要一條跳轉指令。然而,取消優化跳轉表需要預分配,並且必須足夠大以支持整個取消優化 id 的範圍。我們修改了 TurboFan,使得優化代碼中的取消優化點在進入運行時之前直接加載取消優化 id。這使我們完全移除了這個大型跳轉表,以微幅增加優化代碼大小為代價。
結果
我們在過去的 V8 七次版本更新中釋出了上述的優化。通常,它們首先在 Lite 模式 中實現,然後才帶到 V8 的默認配置中。
在此期間,我們已經將 V8 的堆大小平均減少了 18%,涵蓋了多個典型網站,這相當於在低端 AndroidGo 移動設備上平均減少了 1.5 MB。同時,在基準測試或者測量真實網頁交互上,JavaScript 性能並未受到顯著影響。
Lite 模式可以通過禁用函數優化進一步節省記憶體,但會以 JavaScript 執行吞吐量為代價。平均而言,Lite 模式節省 22% 記憶體,有些網頁的記憶體減少高達 32%。這相當於在 AndroidGo 設備上的 V8 堆大小減少了 1.8 MB。
如果拆分影響每個獨立優化的效益,不同的頁面從每種優化中獲得的效益比例有所不同。未來,我們將繼續識別可能的優化方向,它們可以進一步降低 V8 的記憶體使用,同時仍能保持 JavaScript 執行的極佳速度。