嵌入式內建函數
V8 的內建函數 (builtins) 在每個 V8 實例中消耗記憶體。內建函數的數量、平均大小以及每個 Chrome 瀏覽器分頁的 V8 實例數量顯著增加。本文介紹了我們如何在過去一年內將每個網站的V8堆積大小中位數減少了19%。
背景
V8 附帶了廣泛的 JavaScript (JS) 內建函數庫。許多內建函數直接以安裝在 JS 內建物件上的函數形式暴露給 JS 開發者,例如 RegExp.prototype.exec
和 Array.prototype.sort
;其他內建函數實現了各種內部功能。內建函數的機器碼由V8自己的編譯器生成,並在初始化時加載到每個 V8 隔離的管理堆狀態中。一個隔離代表一個獨立的 V8 引擎實例,每個 Chrome 瀏覽器分頁至少包含一個隔離。每個隔離有其自己的管理堆,因而擁有所有內建函數的副本。
早在 2015 年,內建函數主要以自宿主 JS、本地組合語言或 C++ 實現。它們相對較小,為每個隔離創建一個副本問題不大。
過去幾年來,此方面發生了許多變化。
2016 年,V8 開始嘗試使用 CodeStubAssembler (CSA) 實現的內建函數。這被證明既方便(平台獨立、可讀性高),又能生成高效的程式碼,因此 CSA 內建函數變得廣泛使用。由於種種原因,CSA 內建函數往往生成更大的程式碼,隨著越來越多的內建函數被移植到 CSA,內建函數的大小大約增加了三倍。到 2017 年中期,其每個隔離的成本顯著增加,我們開始考慮系統化的解決方案。
2017 年底,我們實施了 內建函數及字節碼處理器的懶惰反序列化 作為第一步。我們的初步分析顯示,大多數網站使用的內建函數不到一半。通過懶惰反序列化,內建函數按需加載,未使用的內建函數永遠不會加載到隔離中。懶惰反序列化在 Chrome 64 中推出,節省了可期的記憶體。但:內建函數的記憶體開銷仍然隨隔離的數量線性增長。
然後,Spectre 被披露,Chrome 最終開啟了網站隔離來減輕其影響。網站隔離將 Chrome 渲染器進程限制為單一來源的文檔。因此,使用網站隔離後,許多瀏覽分頁創建了更多的渲染器進程和更多的 V8 隔離。即使管理每個隔離的開銷一向重要,但網站隔離使之更為突出。
嵌入式內建函數
我們這個項目的目標是完全消除每個隔離的內建函數開銷。
其背後的理念十分簡單。概念上,內建函數在不同隔離中是相同的,只因為實現細節才與隔離相關聯。如果我們能使內建函數真正與隔離無關,我們就可以在記憶體中保留一個副本並在所有隔離中共享它們。而如果我們可以使它們與進程無關,它們甚至可以在進程之間共享。
實際操作中,我們面臨了幾個挑戰。生成的內建函數程式碼因嵌入指向隔離和進程特定數據的指標而既非與隔離無關,也非與進程無關。V8 沒有執行管理堆外生成程式碼的概念。內建函數必須跨進程共享,理想情況下通過重用現有的作業系統機制。最後(這被證明為長尾問題),性能不受明顯影響。
以下部分詳細描述了我們的解決方案。
與隔離及進程無關的程式碼
內建函數由 V8 的編譯器內部管道生成,其中嵌入了對堆常量(位於隔離的管理堆上的位置)、呼叫目標(Code
物件,亦位於管理堆上)以及指向隔離和進程特定地址的引用(例如:C 運行時函數或指向隔離本身的指標,也稱為「外部引用」)的引用直接進程程式碼。在 x64 組合語言中,載入此類物件的操作可能如下所示:
// 將嵌入的地址加載到寄存器 rbx 中。
REX.W movq rbx,0x56526afd0f70
V8 擁有一個移動垃圾收集器,目標對象的位置可能隨時間改變。如果目標在收集期間被移動,GC 會更新生成的代碼以指向新位置。
在 x64(以及大多數其他架構)上,對其他 Code
對象的調用使用高效的調用指令,通過從當前程序計數器的偏移量指定調用目標(一個有趣的細節:V8 在啟動時保留其整個 CODE_SPACE
於受管堆上,以確保所有的 Code 對象之間始終保持可尋址的偏移量)。相關的調用序列如下所示:
// 調用指令位於 [pc + <offset>]。
call <offset>
Code 對象本身位於受管堆上並且是可移動的。當它們被移動時,GC 會更新所有相關調用位置的偏移量。
為了在過程間共享 builtins,生成的代碼必須是不可變的,同時與 Isolate 和過程獨立。以上的兩個指令序列均不滿足此要求:它們在代碼中直接嵌入地址,並且在運行時由 GC 進行修補。
為了解決這兩個問題,我們引入了一個通過專用的根寄存器(root register)的間接方法,此寄存器持有指向當前 Isolate 內已知位置的指針。
V8 的 Isolate
類包含根表,其自身包含指向受管堆上的根對象的指針。根寄存器永久保存根表的地址。
新的與 Isolate 和過程獨立的加載根對象方式如下:
// 加載位於距離根表給定位移的常量地址。
// 偏移量設置於 roots 表。
REX.W movq rax,[kRootRegister + <offset>]
像上面這樣,根堆上的常量可直接從根列表加載。其他堆常量使用通過全局 builtins 常量池的附加間接方法,而常量池自身存儲於根列表中:
// 加載 builtins 常量池, 然後加載所需常量。
REX.W movq rax,[kRootRegister + <offset>]
REX.W movq rax,[rax + 0x1d7]
對於 Code
目標,我們最初切換到更複雜的調用序列,其中會從全局 builtins 常量池中加載目標 Code
對象,將目標地址加載到寄存器中,最後執行間接調用。
隨著這些修改,生成的代碼變得與 Isolate 和過程獨立,我們可以開始在過程之間共享它。
跨過程共享
我們最初評估了兩種替代方法。builtins 可以通過將數據二進制文件用 mmap
映射到內存中來共享;或者,可以直接嵌入到二進制文件中。我們採用了後者,因為它具有使用操作系統的標準機制自動共享進程間內存的優點,並且此更改不需要 V8 嵌入者(例如 Chrome)進行額外的邏輯工作。像 Dart 的 AOT 編譯 成功嵌入生成的代碼那樣,我們對此方法充滿信心。
可執行二進制文件分為幾個部分。例如,一個 ELF 二進制文件包含 .data
(已初始化數據)、.ro_data
(已初始化只讀數據) 和 .bss
(未初始化數據)部分,而本地執行代碼被放置在 .text
部分中。我們的目標是將 builtins 代碼打包到 .text
部分中與本地代碼一起。
這是通過引入一個新的構建步驟來完成的,該步驟使用 V8 的內部編譯器流水線為所有 builtins 生成本地代碼,並將其內容輸出到 embedded.cc
文件中。此文件隨後編譯到最終的 V8 二進制文件中。
embedded.cc
文件本身包含元數據和生成的 builtins 機器代碼,這些代碼以一系列 .byte
指令組成,這些指令指示 C++ 編譯器(在我們的情況下是 clang 或 gcc)將指定的字節序列直接放置到輸出對象文件(及後續的可執行文件)中。
// 嵌入的 builtins 信息包含在元數據表中。
V8_EMBEDDED_TEXT_HEADER(v8_Default_embedded_blob_)
__asm__(".byte 0x65,0x6d,0xcd,0x37,0xa8,0x1b,0x25,0x7e\n"
[略元數據]
// 接下來是生成的機器代碼。
__asm__(V8_ASM_LABEL("Builtins_RecordWrite"));
__asm__(".byte 0x55,0x48,0x89,0xe5,0x6a,0x18,0x48,0x83\n"
[略 builtins 代碼]
.text
部分的內容在運行時被映射到只讀可執行內存中,只要內存僅包含位置無關代碼且無非移動符號,操作系統就會在進程間共享內存。這正是我們所希望的。
但 V8 的 Code
物件不僅包含指令流,還包含各種(有時是與 isolate 有關的)中繼資料。普通的 Code
物件將中繼資料與指令流打包到位於管理堆上的一個可變大小的 Code
物件中。
如我們所見,嵌入式 builtins 的原生指令流位於管理堆之外,嵌入到.text
段中。為了保留它們的中繼資料,每個嵌入式 builtin 都在管理堆上有一個小型的相關 Code
物件,稱為 堆外 trampoline。中繼資料像對於普通的 Code
物件一樣存儲在 trampoline 上,而內聯指令流只包含一個短序列,用於載入嵌入指令的地址並跳至該處。
trampoline 使得 V8 能夠統一處理所有的 Code
物件。對於大多數用途而言,給定的 Code
物件是指標準代碼還是嵌入式 builtin 是無關緊要的。
性能優化
按照前面章節描述的解決方案,嵌入式 builtins 本質上已經功能完整,但基準測試顯示它們帶來顯著的性能下降。例如,我們最初的解決方案使 Speedometer 2.0 的整體性能降低了超過 5%。
我們開始尋找優化的機會,並識別出主要的性能瓶頸。生成的代碼因頻繁間接訪問 isolate 和進程相關物件而變慢。根常數從根列表載入(1 次間接訪問),其他堆常數從全局 builtins 常數池載入(2 次間接訪問),外部引用還需要從堆物件中解包(3 次間接訪問)。最糟糕的是我們的新調用序列,它需要載入 trampoline 的 Code 物件,調用它,然後才跳到目標地址。最後,在管理堆和二進制嵌入代碼之間的調用似乎本質上更慢,可能因長距離跳轉干擾了 CPU 的分支預測。
因此,我們的工作集中在 1. 降低間接訪問,2. 改善 builtin 調用序列。為了解決前者,我們改變了 Isolate 物件佈局,將大多數物件載入改為單次相對根的載入。全局 builtins 常數池仍然存在,但僅包含不常訪問的物件。
調用序列在兩個方面得到了顯著改進。Builtin 與 Builtin 之間的調用被轉換為單個 pc 相對的調用指令。這對於運行時生成的 JIT 代碼不可行,因為 pc 相對偏移量可能超過最大 32 位值。在這裡,我們在所有調用站點內聯了堆外 trampoline,將調用序列從 6 條指令減少到僅 2 條指令。
通過這些優化,我們能夠將 Speedometer 2.0 的回退限制在約 0.5%。
結果
我們評估了嵌入式 builtins 在 x64 平台上對最受歡迎的 10k 網站的影響,並與延遲反序列化和預先反序列化(如上所述)進行了比較。
以前 Chrome 附帶的內存映射 snapshot 在每個 Isolate 上反序列化,而現在 snapshot 被嵌入式 builtins 取代,仍然是內存映射但不需要反序列化。以前 builtins 的成本是 c*(1 + n)
,其中 n
是 Isolates 的數量,c
是所有 builtins 的內存成本;而現在僅為 c * 1
(實際上,堆外 trampoline 的少量每 Isolate 的開銷仍然存在)。
與預先反序列化相比,我們將 V8 堆大小中位數減少了 19%。每個站點的 Chrome 渲染進程大小中位數減少了 4%。以絕對數字計算,第 50 百分位節省了 1.9 MB,第 30 百分位節省了 3.4 MB,第 10 百分位節省了 6.5 MB。
一旦字節碼處理器也嵌入二進制,預計還會有顯著額外的內存節省。
嵌入式 builtins 正在 Chrome 69 上針對 x64 平台推出,移動平台將在 Chrome 70 中跟進。對 ia32 的支持預計將於 2018 年末發布。
注意: 所有圖表均使用 Vyacheslav Egorov 卓越的 Shaky Diagramming 工具生成。