咱們是怎樣優化 V8 中的指針壓縮的

做者: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% 。程序員

image.png

指針壓縮(Pointer Compression)是 V8 中爲減小內存消耗而進行的多項努力之一。這個想法很簡單:咱們能夠存儲一些「基」地址的 32 位偏移量,而不是存儲 64 位指針。有了這樣一個簡單的想法,那麼能夠從 V8 的這種壓縮中得到多少收益?github

V8 堆包含一整套項目,例如浮點值、字符串字符、解釋器字節碼和標記值(有關詳細信息,請參見下一節)。在檢查堆以後,咱們發如今現實世界的網站上,這些標記值約佔 V8 堆的 70% 之多!web

讓咱們仔細看看什麼是標記值。面試

V8 中的 Value tagging

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 位:

  • 確保全部 V8 對象都分配在 4 GB 的內存範圍內
  • 將指針表示爲該範圍內的偏移量

如此嚴格的限制是不幸的,可是 Chrome 中的 V8 對 V8 堆的大小已經有 2 GB 或 4 GB 的限制(取決於基礎設備的功能),即便在 64 位架構上也是如此。其餘嵌入 V8 的程序,例如 Node.js,可能須要更大的堆。若是咱們施加最大 4 GB 的空間,則意味着這些嵌入器沒法使用指針壓縮。

如今的問題是如何更新堆佈局,以確保 32 位指針可以惟一標識 V8 對象。

瑣碎的堆佈局

簡單的壓縮方案是在前 4 GB 的地址空間中分配對象。

image.png

不幸的是,這不是 V8 的選項,由於 Chrome 的渲染器進程可能須要在同一渲染器進程中建立多個 V8 實例,例如,針對 Web/Service Workers。不然使用此方案,全部這些 V8 實例都會爭奪相同的 4 GB 地址空間,所以全部 V8 實例一塊兒受到 4 GB 內存的限制。

堆佈局,v1

若是咱們將 V8 的堆放在地址空間的連續 4 GB 區域中的其餘位置,則從基址(base)開始的無符號 32 位偏移量將會惟一地標識指針。

堆佈局,以 base 爲基準開始

若是咱們還確保基址是 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);
}

下面讓咱們嘗試更改壓縮方案以簡化解壓縮代碼。

堆佈局,v2

若是不是將基址放在 4 GB 的開頭,而是放在 中間 ,則能夠將壓縮值視爲距離基址的 32 位有符號偏移量。請注意,整個預留再也不是 4 GB 對齊的,而是基址的。

image.png

在這種新佈局中,壓縮代碼保持不變。

可是解壓縮代碼變得更好了。如今符號擴展在 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 版本,綠線則是指針壓縮版本。

image.png

在第一個可行的實施方案中,咱們性能損失約爲 35%。

優化 (1), +7%

首先,經過將無分支解壓縮與有分支解壓縮相比較,驗證了「無分支更快」的假設。事實證實,咱們的假設是錯誤的,在 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 中的分支預測器很是好,而且代碼大小(尤爲是執行路徑長度)對性能的影響更大。

優化(2),+ 2%

TurboFan 是V8的優化編譯器,其構建基於「Sea of Nodes」的概念。簡而言之,每一個操做都在節點圖中表示爲一個節點(請參見 https://v8.dev/blog/turbofan-jit 更詳細的版本)。這些節點具備各類依賴性,包括數據流和控制流。

對於指針壓縮,有兩個相當重要的操做:加載和存儲,由於它們把 V8 堆與管道的其他部分鏈接起來。若是每次在堆中加載壓縮值時都進行解壓縮,而後在存儲以前對其進行壓縮,則管道就可以像在全指針模式下同樣繼續工做。所以咱們在節點圖中添加了新的顯式操做——解壓縮和壓縮。

在某些狀況下,實際上不須要解壓縮。例如僅從某處加載壓縮值,而後將其存儲到新位置。

爲了優化沒必要要的操做,咱們在 TurboFan 中實現了一個新的「解壓消除」階段。它的工做是消除直接進行壓縮後的解壓縮。因爲這些節點可能不會彼此直接相鄰,所以它還會嘗試經過圖傳播解壓縮,以期遇到壓縮問題並消除它們。這使咱們的 Octane 得分提升了2%。

優化(3),+2%

在查看生成的代碼時,咱們注意到對剛加載的值進行解壓縮會產生一些過於冗長的代碼:

movl rax, <mem>   // load
movlsxlq rax, rax // sign extend

一旦咱們修復了簽名問題,就能夠直接擴展從內存加載的值:

movlsxlq rax, <mem>

所以又提升了2%。

優化(4),+ 11%

TurboFan 優化階段經過在圖上使用模式匹配來工做:一旦子圖與某個特定模式匹配,它將被語義上等效(但更好)的子圖或指令替換。

找不到匹配項的失敗嘗試不是明確的失敗。圖中顯式的「解壓縮/壓縮」操做的存在致使先前成功的模式匹配嘗試再也不成功,從而致使優化無提示地失敗。

「中斷」優化的一個例子是分配預選。一旦咱們更新了模式匹配,意識到新的壓縮/解壓縮節點,咱們又得到了11%的改進。

進一步改進

image.png

優化(5),+ 0.5%

在 TurboFan 中實施消除解壓縮功能時,咱們學到了不少東西。顯式「解壓縮/壓縮」節點方法具備如下屬性:
優勢:

  • 此類操做的明確性使咱們可以經過對子圖進行規範的模式匹配來優化掉沒必要要的解壓縮。

可是,隨着咱們繼續實施,發現了缺點:

  • 因爲新的內部值表示形式,可能的轉換操做組合爆炸變得難以管理。除了現有的表示集(帶標籤的 Smi、帶標籤的指針、帶標籤的 any、word八、word1六、word3二、word6四、float3二、float6四、simd128)。
  • 某些基於圖模式匹配的現有優化沒有成功執行,從而致使了一些地方的惡化。儘管咱們找到並修復了其中一些問題,但 TurboFan 的複雜性仍在增長。
  • 寄存器分配器對圖中的節點數量愈來愈不滿意,而且常常會生成錯誤代碼。
  • 較大的節點圖會減緩 TurboFan 優化階段,並增長編譯期間的內存消耗。

咱們決定退一步,考慮一種在 TurboFan 中支持指針壓縮的更簡單方法。新方法是刪除 Compressed Pointer/Smi/Any 表示,並使全部顯式的 壓縮/解壓縮節點隱含在「加載和存儲中,並假設咱們始終在加載以前進行解壓縮,並在存儲以前進行壓縮。

咱們還在 TurboFan 中增長了一個新階段,它將取代「解壓消除」階段。這個新階段能夠識別出咱們什麼時候實際上不須要壓縮或解壓縮,並相應地更新「加載和存儲」。這種方法顯着下降了 TurboFan 中指針壓縮支持的複雜性,並提升了生成代碼的質量。

新的實現與原始版本同樣有效,而且又提升了0.5%。

優化(6),+ 2.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 預留基址的位置。

image.png

就解壓代碼而言,它會將符號擴展操做更改成零擴展,這代價一樣很小。可是這簡化了運行時(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 位構建的兩次優化來解釋,這些優化因爲與指針壓縮根本不兼容而不得不由用。

image.png

32 位 Smi 優化(7),-1%

回想一下 Smis 在 64 位架構上全指針模式下的樣子。

|----- 32 bits -----|----- 32 bits -----|
Smi:    |____int32_value____|0000000000000000000|

32 位 Smi 具備如下優勢:

  • 它能夠表示更大範圍的整數,而無需將它們裝箱成數字對象;
  • 這樣的形態能夠在讀取/寫入時直接訪問 32 位值。

指針壓縮沒法完成這種優化,由於 32 位壓縮指針中沒有空格,它具備區分指針和 Smis 的位。若是在全指針 64 位版本中禁用 32 位 smis,咱們將看到 Octane 分數下降 1%。

雙字段拆箱(8),-3%

在某些假設下,這種優化嘗試將浮點值直接存儲在對象的字段中。其目的是減小數量對象分配的數量,甚至比 Smis 單獨執行的數量更多。

思考如下 JavaScript 代碼:

function Point(x, y) {
  this.x = x;
  this.y = y;
}
let p = new Point(3.1, 5.3);

通常來講,若是咱們看一下對象 p 在內存中的樣子,就會看到如下內容:

image.png

你能夠在這篇文章中(https://v8.dev/blog/fast-prop...)瞭解關於隱藏的類與屬性以及元素後備存儲的更多信息。

在 64 位體系結構上,雙精度值的大小與指針的大小相同。所以,若是咱們假設 Point 的字段始終包含數字值,則能夠將其直接存儲在對象字段中。

image.png

若是對某個字段的假設成立,會執行這行代碼:

let q = new Point(2, 「ab」);

而後必須將 y 屬性的數字值裝箱保存。此外,若是某個地方的推測優化代碼依賴這個假設,則必須再也不使用它,而且必須將其丟棄(取消優化)。進行這種「字段類型」泛化的緣由是要最小化從同一構造函數建立的對象的圖數量,而這又對於更穩定的性能是必需的。

image.png

若是生效,則雙字段拆箱有如下好處:

  • 經過對象指針提供對浮點數據的直接訪問,避免經過數字對象進行額外的取消引用操做;
  • 容許咱們爲執行大量雙字段訪問的緊密循環生成更小、更快的優化代碼(例如在數字運算程序中)

啓用指針壓縮後,雙精度值根本再也不適合壓縮字段。可是未來咱們可能會將這種優化用於指針壓縮。

請注意,即便沒有這種雙字段拆箱優化(以與指針壓縮兼容的方式),也能夠經過將數據存儲在 Float64 TypedArrays 中,甚至用於 Wasm

更多改進(9),1%

最後,在 TurboFan 中對解壓消除優化進行了一些微調,使性能又提升了1%。

一些實現細節

爲了簡化指針壓縮與現有代碼的集成,咱們決定在每次加載時對值進行解壓縮,並在每一個存儲中對它們進行壓縮。因此僅更改標記值的存儲格式,同時保持執行格式不變。

本機代碼

爲了可以在須要解壓縮時生成有效的代碼,必須始終提供基址值。幸運的是,V8 已經有一個專用寄存器,始終指向「根表」,其中包含對 JavaScript 和 V8 內部對象的引用,這些引用必須始終可用(例如:undefined、null、true、false 等)。該寄存器稱爲「根寄存器」,用於生成較小的可共享的內置代碼

因此咱們將根表放入 V8 堆保留區,根寄存器可同時用於兩個目的——做爲根指針和解壓縮的基址。

C++ 層面

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%

image.png

另外一個須要注意的是,並不是每一個網站都能獲得相同的提升。例如,訪問 Facebook 時的 V8 堆內存曾經比《紐約時報》大,但使用指針壓縮時是相反的。這種差別能夠經過如下事實來解釋:某些網站有比其餘網站更多的標記值。

除了這些內存改進以外,咱們還看到了實際性能的改進。在真實的網站上,咱們獲得了更少的 CPU 消耗和垃圾回收時間!

image.png

結論

儘管一路上沒有鳥語花香,但值得咱們經歷。在 300+次提交以後,使用指針壓縮的 V8 所使用的內存與運行 32 位程序時同樣,而具備 64 位程序的性能。

咱們一直持續改進,並在流程中完成以了下相關任務:

  • 提升生成的彙編代碼的質量。咱們知道,在某些狀況下能夠生成更少的代碼來提升性能。
  • 解決了相關的性能降低問題,包括一種機制,該機制容許以指針壓縮友好的方式再次對雙字段取消裝箱。
  • 探索支持 8 到 16 GB 範圍內的更大堆的想法。

本文首發微信公衆號:前端先鋒

歡迎掃描二維碼關注公衆號,天天都給你推送新鮮的前端技術文章

歡迎掃描二維碼關注公衆號,天天都給你推送新鮮的前端技術文章

歡迎繼續閱讀本專欄其它高贊文章:


相關文章
相關標籤/搜索