做者:V8開發團隊 Lgor Sheludko & Santiago Aboy Solanes翻譯:瘋狂的技術宅javascript
原文:https://v8.dev/blog/pointer-c...html
未經容許嚴禁轉載前端
內存和性能之間始終存在爭鬥。做爲用戶,咱們但願執行得更快而且消耗的內存更少。不幸的是,一般提升性能的代價是消耗內存(反之亦然)。java
早在 2014 年,Chrome 就從 32 位進程轉換爲 64 位進程。這爲 Chrome 提供了更好的安全性,穩定性和性能,但它爲每一個指針所消耗的內存由 4 個字節變爲 8 個字節。咱們面臨的挑戰是要減小 V8 中的這種開銷,去嘗試儘量多地獲取被浪費的那 4 個字節。git
在深刻研究具體實現以前,須要知道咱們所處的位置來對狀況進行正確的評估。爲了衡量咱們的內存和性能,咱們使用了一組反映現實中流行網站的 web 頁面。數據顯示,V8 的內存消耗佔到桌面版 Chrome 渲染器進程的 60%,平均佔 40% 。程序員
指針壓縮(Pointer Compression)是 V8 中爲減小內存消耗而進行的多項努力之一。這個想法很簡單:咱們能夠存儲一些「基」地址的 32 位偏移量,而不是存儲 64 位指針。有了這樣一個簡單的想法,那麼能夠從 V8 的這種壓縮中得到多少收益?github
V8 堆包含一整套項目,例如浮點值、字符串字符、解釋器字節碼和標記值(有關詳細信息,請參見下一節)。在檢查堆以後,咱們發如今現實世界的網站上,這些標記值約佔 V8 堆的 70% 之多!web
讓咱們仔細看看什麼是標記值。面試
V8 中的 JavaScript 值表示爲對象,並在 V8 堆上進行分配,不管它們是對象、數組、數字仍是字符串。這使咱們能夠把任何值都表示爲指向對象的指針。segmentfault
許多 JavaScript 程序都會對整數值執行計算,例如在循環中增長索引。爲了不每次整數遞增時都必須分配新的數字對象,V8 使用了衆所周知的 pointer tagging 技術在 V8 堆指針中存儲額外的或替代數據。
標記位具備雙重目的:用於指示位於 V8 堆中對象的強/弱指針或者一個小整數的信號。所以,整數值能夠直接存儲在標記值中,而沒必要爲其分配額外的存儲空間。
V8 老是在堆中按照字對齊的地址分配對象,這使它可使用 2 個(或3個,取決於機器字的大小)最低有效位進行標記。在 32 位體系結構上,V8 使用最低有效位將 Smis 與堆對象指針區分開。對於堆指針,它使用第二個最低有效位來區分強引用和弱引用:
|----- 32 bits -----| Pointer: |_____address_____w1| Smi: |___int31_value____0|
w 是用於區分強指針和弱指針的位。
請注意,Smi 值只能攜帶31位有效負載,包括符號位。對於指針,咱們有 30 位能夠用做堆對象地址有效負載。因爲字對齊的緣由,分配粒度爲4個字節,這就給了咱們 4 GB 的可尋址空間。
在 64 位體系結構上,V8 值以下所示:
|----- 32 bits -----|----- 32 bits -----| Pointer: |________________address______________w1| Smi: |____int32_value____|0000000000000000000|
你可能會注意到,與 32 位體系結構不一樣,在 64 位體系結構上,V8 能夠將 32 位用於 Smi 值的有效負載。如下各節將討論 32 位 Smis 對指針壓縮的影響。
咱們的目標是使用指針壓縮,以某種方式使兩種標記值在64 位架構上都適合32 位。能夠經過如下方式將指針調整爲 32 位:
如此嚴格的限制是不幸的,可是 Chrome 中的 V8 對 V8 堆的大小已經有 2 GB 或 4 GB 的限制(取決於基礎設備的功能),即便在 64 位架構上也是如此。其餘嵌入 V8 的程序,例如 Node.js,可能須要更大的堆。若是咱們施加最大 4 GB 的空間,則意味着這些嵌入器沒法使用指針壓縮。
如今的問題是如何更新堆佈局,以確保 32 位指針可以惟一標識 V8 對象。
簡單的壓縮方案是在前 4 GB 的地址空間中分配對象。
不幸的是,這不是 V8 的選項,由於 Chrome 的渲染器進程可能須要在同一渲染器進程中建立多個 V8 實例,例如,針對 Web/Service Workers。不然使用此方案,全部這些 V8 實例都會爭奪相同的 4 GB 地址空間,所以全部 V8 實例一塊兒受到 4 GB 內存的限制。
若是咱們將 V8 的堆放在地址空間的連續 4 GB 區域中的其餘位置,則從基址(base)開始的無符號 32 位偏移量將會惟一地標識指針。
若是咱們還確保基址是 4 GB 對齊的,則全部指針的高 32 位相同:
|----- 32 bits -----|----- 32 bits -----| Pointer: |________base_______|______offset_____w1|
經過將 Smi 有效負載限制爲 31 位並將其放置在低 32 位,咱們還可使 Smis 可壓縮。基本上使它們相似於 32 位體系結構上的 Smis。
|----- 32 bits -----|----- 32 bits -----| Smi: |sssssssssssssssssss|____int31_value___0|
其中 s 是 Smi 有效負載的符號值。若是咱們使用符號擴展表示,則只需對 64 位字進行一次位算術移位就能夠對 Smis 進行壓縮和解壓縮。
如今咱們能夠看到指針和 Smis 的上半字徹底由下半字定義。而後咱們能夠將後者僅存儲在內存中,從而將存儲標記值所需的內存減小一半:
|----- 32 bits -----|----- 32 bits -----| Compressed pointer: |______offset_____w1| Compressed Smi: |____int31_value___0|
假定基址是 4 GB 對齊的,則壓縮只是一個截斷:
uint64_t uncompressed_tagged; uint32_t compressed_tagged = uint32_t(uncompressed_tagged);
可是,解壓縮代碼要複雜一些。咱們須要區分符號擴展的 Smi 和零擴展的指針,以及是否要添加基址。
uint32_t compressed_tagged; uint64_t uncompressed_tagged; if (compressed_tagged & 1) { // pointer case uncompressed_tagged = base + uint64_t(compressed_tagged); } else { // Smi case uncompressed_tagged = int64_t(compressed_tagged); }
下面讓咱們嘗試更改壓縮方案以簡化解壓縮代碼。
若是不是將基址放在 4 GB 的開頭,而是放在 中間 ,則能夠將壓縮值視爲距離基址的 32 位有符號偏移量。請注意,整個預留再也不是 4 GB 對齊的,而是基址的。
在這種新佈局中,壓縮代碼保持不變。
可是解壓縮代碼變得更好了。如今符號擴展在 Smi 和指針狀況下都是常見的,惟一的分支是是否在指針狀況下添加基址。
int32_t compressed_tagged; // Common code for both pointer and Smi cases int64_t uncompressed_tagged = int64_t(compressed_tagged); if (uncompressed_tagged & 1) { // pointer case uncompressed_tagged += base; }
代碼中分支的性能取決於 CPU 中的分支預測單元。咱們認爲,若是以無分支方式實施減壓,則能夠獲得更好的性能。藉助少許的魔術,咱們能夠編寫上述代碼的無分支版本:
int32_t compressed_tagged; // Same code for both pointer and Smi cases int64_t sign_extended_tagged = int64_t(compressed_tagged); int64_t selector_mask = -(sign_extended_tagged & 1); // Mask is 0 in case of Smi or all 1s in case of pointer int64_t uncompressed_tagged = sign_extended_tagged + (base & selector_mask);
而後,咱們決定從無分支的實現開始。
咱們在 Octane 上測量了性能,這是咱們過去使用的最高性能基準。儘管再也不專一於在平常工做中提升峯值性能,但也不想下降峯值性能,特別是對於性能敏感的事物,例如全部的指針。Octane 值仍然是這個任務的良好基準。
該圖顯示了 Octane 在優化和優化指針壓縮時在 x64 架構上的得分。在圖中,得分越高越好。紅線是現有的無壓縮指針 x64 版本,綠線則是指針壓縮版本。
在第一個可行的實施方案中,咱們性能損失約爲 35%。
首先,經過將無分支解壓縮與有分支解壓縮相比較,驗證了「無分支更快」的假設。事實證實,咱們的假設是錯誤的,在 x64上,分支版本的速度提升了 7%。這區別是很大的!
讓咱們看一下 x64 彙編代碼。
Decompression | 無分支 | Branchful |
---|---|---|
Code | movsxlq r11,[…] movl r10,r11 andl r10,0x1 negq r10 andq r10,r13 addq r11,r10 |
movsxlq r11,[…] testb r11,0x1 jz done addq r11,r13 done: |
總結 | 20 bytes | 13 bytes |
執行了 6 條指令 | 執行了 3 或 4 條指令 | |
no branches | 1 branch | |
1 個額外的寄存器 |
r13 是用於基址值的專用寄存器。請注意,無分支代碼量更大且須要更多寄存器。
在 Arm64上,咱們觀察到了相同的結果——分支版本在功能強大的 CPU 上明顯更快(儘管兩種代碼大小相同)。
Decompression | Branchless | Branchful |
---|---|---|
Code | ldur w6, […] sbfx x16, x6, #0, #1 and x16, x16, x26 add x6, x16, w6, sxtw |
ldur w6, […] sxtw x6, w6 tbz w6, #0, #done add x6, x26, x6 done: |
Summary | 16 bytes | 16 bytes |
執行了 4 條指令 | 執行了 3 或 4 條指令 | |
沒有分支 | 1 個分支 | |
1個額外的寄存器 |
咱們觀察到在低端 Arm64 設備上幾乎沒有性能差別。
咱們的收穫是:現代 CPU 中的分支預測器很是好,而且代碼大小(尤爲是執行路徑長度)對性能的影響更大。
TurboFan 是V8的優化編譯器,其構建基於「Sea of Nodes」的概念。簡而言之,每一個操做都在節點圖中表示爲一個節點(請參見 https://v8.dev/blog/turbofan-jit 更詳細的版本)。這些節點具備各類依賴性,包括數據流和控制流。
對於指針壓縮,有兩個相當重要的操做:加載和存儲,由於它們把 V8 堆與管道的其他部分鏈接起來。若是每次在堆中加載壓縮值時都進行解壓縮,而後在存儲以前對其進行壓縮,則管道就可以像在全指針模式下同樣繼續工做。所以咱們在節點圖中添加了新的顯式操做——解壓縮和壓縮。
在某些狀況下,實際上不須要解壓縮。例如僅從某處加載壓縮值,而後將其存儲到新位置。
爲了優化沒必要要的操做,咱們在 TurboFan 中實現了一個新的「解壓消除」階段。它的工做是消除直接進行壓縮後的解壓縮。因爲這些節點可能不會彼此直接相鄰,所以它還會嘗試經過圖傳播解壓縮,以期遇到壓縮問題並消除它們。這使咱們的 Octane 得分提升了2%。
在查看生成的代碼時,咱們注意到對剛加載的值進行解壓縮會產生一些過於冗長的代碼:
movl rax, <mem> // load movlsxlq rax, rax // sign extend
一旦咱們修復了簽名問題,就能夠直接擴展從內存加載的值:
movlsxlq rax, <mem>
所以又提升了2%。
TurboFan 優化階段經過在圖上使用模式匹配來工做:一旦子圖與某個特定模式匹配,它將被語義上等效(但更好)的子圖或指令替換。
找不到匹配項的失敗嘗試不是明確的失敗。圖中顯式的「解壓縮/壓縮」操做的存在致使先前成功的模式匹配嘗試再也不成功,從而致使優化無提示地失敗。
「中斷」優化的一個例子是分配預選。一旦咱們更新了模式匹配,意識到新的壓縮/解壓縮節點,咱們又得到了11%的改進。
在 TurboFan 中實施消除解壓縮功能時,咱們學到了不少東西。顯式「解壓縮/壓縮」節點方法具備如下屬性:
優勢:
可是,隨着咱們繼續實施,發現了缺點:
咱們決定退一步,考慮一種在 TurboFan 中支持指針壓縮的更簡單方法。新方法是刪除 Compressed Pointer/Smi/Any 表示,並使全部顯式的 壓縮/解壓縮節點隱含在「加載和存儲中,並假設咱們始終在加載以前進行解壓縮,並在存儲以前進行壓縮。
咱們還在 TurboFan 中增長了一個新階段,它將取代「解壓消除」階段。這個新階段能夠識別出咱們什麼時候實際上不須要壓縮或解壓縮,並相應地更新「加載和存儲」。這種方法顯着下降了 TurboFan 中指針壓縮支持的複雜性,並提升了生成代碼的質量。
新的實現與原始版本同樣有效,而且又提升了0.5%。
儘管已經接近性能均等,可是差距仍然存在。咱們不得不提出新的想法。其中之一是:若是咱們確保處理 Smi 值的代碼都不會處理高 32 位怎麼辦?
讓咱們記住解壓實現:
// Old decompression implementation int64_t uncompressed_tagged = int64_t(compressed_tagged); if (uncompressed_tagged & 1) { // pointer case uncompressed_tagged += base; }
若是忽略了 Smi 的高 32 位,則能夠假定它們是 undefined。這樣,咱們就能夠避免在指針和 Smi 之間使用特殊的大小寫,而且在解壓縮時無條件地添加基址,即便是對於 Smi!咱們稱這種方法爲「Smi-corrupting」。
// New decompression implementation int64_t uncompressed_tagged = base + int64_t(compressed_tagged);
另外因爲咱們再也不關心擴展 Smi 的標誌了,因此這種更改使咱們能夠回到堆佈局 v1。這是一個指向 4GB 預留基址的位置。
就解壓代碼而言,它會將符號擴展操做更改成零擴展,這代價一樣很小。可是這簡化了運行時(C++)端的工做。例如,地址空間區域保留代碼(請參見本文「一些細節實現」這一部分)。
這是用於比較的彙編代碼:
Decompression | Branchful | Smi-corrupting |
---|---|---|
Code | movsxlq r11,[…] testb r11,0x1 jz done addq r11,r13 done: |
movl r11,[rax+0x13] addq r11,r13 |
總結 | 13 bytes | 7 bytes |
執行了 3 或 4 條指令 | 執行 2 條指令 | |
1個分支 | 沒有分支 |
因此咱們把 V8 中全部使用 Smi 的代碼段調整爲新的壓縮方案,這又提升了2.5%。
剩餘的性能差距能夠經過對 64 位構建的兩次優化來解釋,這些優化因爲與指針壓縮根本不兼容而不得不由用。
回想一下 Smis 在 64 位架構上全指針模式下的樣子。
|----- 32 bits -----|----- 32 bits -----| Smi: |____int32_value____|0000000000000000000|
32 位 Smi 具備如下優勢:
指針壓縮沒法完成這種優化,由於 32 位壓縮指針中沒有空格,它具備區分指針和 Smis 的位。若是在全指針 64 位版本中禁用 32 位 smis,咱們將看到 Octane 分數下降 1%。
在某些假設下,這種優化嘗試將浮點值直接存儲在對象的字段中。其目的是減小數量對象分配的數量,甚至比 Smis 單獨執行的數量更多。
思考如下 JavaScript 代碼:
function Point(x, y) { this.x = x; this.y = y; } let p = new Point(3.1, 5.3);
通常來講,若是咱們看一下對象 p 在內存中的樣子,就會看到如下內容:
你能夠在這篇文章中(https://v8.dev/blog/fast-prop...)瞭解關於隱藏的類與屬性以及元素後備存儲的更多信息。
在 64 位體系結構上,雙精度值的大小與指針的大小相同。所以,若是咱們假設 Point 的字段始終包含數字值,則能夠將其直接存儲在對象字段中。
若是對某個字段的假設成立,會執行這行代碼:
let q = new Point(2, 「ab」);
而後必須將 y 屬性的數字值裝箱保存。此外,若是某個地方的推測優化代碼依賴這個假設,則必須再也不使用它,而且必須將其丟棄(取消優化)。進行這種「字段類型」泛化的緣由是要最小化從同一構造函數建立的對象的圖數量,而這又對於更穩定的性能是必需的。
若是生效,則雙字段拆箱有如下好處:
啓用指針壓縮後,雙精度值根本再也不適合壓縮字段。可是未來咱們可能會將這種優化用於指針壓縮。
請注意,即便沒有這種雙字段拆箱優化(以與指針壓縮兼容的方式),也能夠經過將數據存儲在 Float64 TypedArrays 中,甚至用於 Wasm。
最後,在 TurboFan 中對解壓消除優化進行了一些微調,使性能又提升了1%。
爲了簡化指針壓縮與現有代碼的集成,咱們決定在每次加載時對值進行解壓縮,並在每一個存儲中對它們進行壓縮。因此僅更改標記值的存儲格式,同時保持執行格式不變。
爲了可以在須要解壓縮時生成有效的代碼,必須始終提供基址值。幸運的是,V8 已經有一個專用寄存器,始終指向「根表」,其中包含對 JavaScript 和 V8 內部對象的引用,這些引用必須始終可用(例如:undefined、null、true、false 等)。該寄存器稱爲「根寄存器」,用於生成較小的可共享的內置代碼。
因此咱們將根表放入 V8 堆保留區,根寄存器可同時用於兩個目的——做爲根指針和解壓縮的基址。
V8 運行時經過 C++ 類訪問 V8 堆中的對象,從而能夠方便地查看堆中存儲的數據。請注意,V8 對象比 C++ 對象更像 POD 結構。助手類 view
僅包含一個帶有相應標記值的 uintptr_t
字段。因爲view
類是字大小的,所以咱們能夠零開銷將它們按值傳遞(這要感謝現代 C++ 編譯器)。
這是輔助類的僞代碼示例:
// Hidden class class Map { public: … inline DescriptorArray instance_descriptors() const; … // The actual tagged pointer value stored in the Map view object. const uintptr_t ptr_; }; DescriptorArray Map::instance_descriptors() const { uintptr_t field_address = FieldAddress(ptr_, kInstanceDescriptorsOffset); uintptr_t da = *reinterpret_cast<uintptr_t*>(field_address); return DescriptorArray(da); }
爲了最大程度地減小首次運行指針壓縮版本所需的更改次數,咱們將解壓縮所需的基址值的計算集成到 getter 中。
inline uintptr_t GetBaseForPointerCompression(uintptr_t address) { // Round address down to 4 GB const uintptr_t kBaseAlignment = 1 << 32; return address & -kBaseAlignment; } DescriptorArray Map::instance_descriptors() const { uintptr_t field_address = FieldAddress(ptr_, kInstanceDescriptorsOffset); uint32_t compressed_da = *reinterpret_cast<uint32_t*>(field_address); uintptr_t base = GetBaseForPointerCompression(ptr_); uintptr_t da = base + compressed_da; return DescriptorArray(da); }
性能測量結果證明,在每一個負載中計算基址都會損害性能。緣由是C++ 編譯器不知道 V8 堆中的任何地址,GetBaseForPointerCompression()
調用的結果都是相同的,因此編譯器沒法合併基址值的計算。假定代碼由多個指令和 64 位常量組成,這會致使代碼膨脹嚴重。
爲了解決這個問題,咱們重用了 V8 實例指針做爲減壓的基礎(請記住堆佈局中的 V8 實例數據)。該指針一般在運行時函數中可用,所以咱們經過要求使用 V8 實例指針來簡化 getter 代碼,並恢復了性能:
DescriptorArray Map::instance_descriptors(const Isolate* isolate) const { uintptr_t field_address = FieldAddress(ptr_, kInstanceDescriptorsOffset); uint32_t compressed_da = *reinterpret_cast<uint32_t*>(field_address); // No rounding is needed since the Isolate pointer is already the base. uintptr_t base = reinterpret_cast<uintptr_t>(isolate); uintptr_t da = DecompressTagged(base, compressed_value); return DescriptorArray(da); }
讓咱們來看看指針壓縮的最終數字!對於這些結果,咱們用與本文開頭介紹的相同的網站測試。提醒一下,他們表明用戶在真實世界網站使用狀況。
咱們觀察到指針壓縮將 V8堆的大小減小了多達 43%!反過來,它能夠將桌面版的 Chrome 渲染器進程內存減小多達 20%。
另外一個須要注意的是,並不是每一個網站都能獲得相同的提升。例如,訪問 Facebook 時的 V8 堆內存曾經比《紐約時報》大,但使用指針壓縮時是相反的。這種差別能夠經過如下事實來解釋:某些網站有比其餘網站更多的標記值。
除了這些內存改進以外,咱們還看到了實際性能的改進。在真實的網站上,咱們獲得了更少的 CPU 消耗和垃圾回收時間!
儘管一路上沒有鳥語花香,但值得咱們經歷。在 300+次提交以後,使用指針壓縮的 V8 所使用的內存與運行 32 位程序時同樣,而具備 64 位程序的性能。
咱們一直持續改進,並在流程中完成以了下相關任務: