「譯」V8中的指針壓縮

原文連接:v8.dev/blog/pointe…html

內存和性能之間的鬥爭始終存在。做爲用戶,咱們但願速度又快佔用內存又少。然而一般狀況下,提升性能須要消耗更多的內存(反之亦然)。node

時間回到2014年,那時Chrome從32位切換到64位。這個變化帶給了Chrome更好的安全性、穩定性和性能,但同時也帶來了更多內存的消耗,由於以前每一個指針佔用4個字節而如今佔用是8個字節。咱們面臨在V8中儘量減小這種多出來4個字節開銷的挑戰。git

在實施改進以前,咱們須要知道咱們目前的情況,從而正確的評估如何改進。爲了測量當前的內存和性能,咱們使用一組能夠表明目前流行站點的頁面。數據顯示在桌面端Chrome渲染進程內存佔用中V8佔用了60%,平均爲40%。github

指針壓縮是改進V8內存佔用的多項工做之一。想法很簡單:咱們能夠存儲一些「基」地址的32位偏移量而不是存儲64位指針。這樣一個簡單的想法,咱們能夠從V8中的這種壓縮得到多少收益?web

V8的堆區包含大量的項目(items),例如浮點值(floating point values),字符串字符(string characters),解析器字節碼(interpreter bytecode)和標記值(tagged values)。在檢查堆區時,咱們發如今現實使用的網站中,這些標記值佔了V8堆區的70%!windows

下面咱們具體看看這些標記值是什麼。數組

V8中的標記值

在V8中JavaScript的對象,數組,數字或者字符串都用對象表示,分配在V8堆區。這使得咱們能夠用一個指向對象的指針表示任何值。安全

許多JavaScript程序都會對整數進行計算,例如在循環中增長索引。爲了不每次整數遞增時從新分配一個新的number對象,V8使用著名的指針標記技術(pointer tagging)在V8的堆指針中存儲其餘或替代數據。bash

標記位(tag bits)有雙重做用:用於指示位於V8堆中對象的強/弱指針或一個小整數的信號。所以,整數可以直接存儲在標記值中,而沒必要爲其分配額外的存儲空間。架構

V8在堆中按字對齊的地址分配對象,這使得它可使用2(或3,取決於機器字大小)最低有效位進行標記。在32位架構中,V8使用最低有效位去區分Smis和堆對象指針。對於堆指針,它使用第二個最低有效位去區分強引用和弱引用:

|----- 32 bits -----|
Pointer:                |_____address_____w1|
Smi:                    |___int31_value____0|
複製代碼

這裏的 w 用來區分強指針和弱指針。

*注意:*一個Smi值只能攜帶一個31bit有效載荷(payload),包括符號位。對於指針,咱們有30bit用來做爲堆對象地址有效載荷(payload)。因爲字對齊,分配粒度爲4個字節,這給了咱們4GB的尋址空間。

在64位架構中,V8的值看起來像這樣:

|----- 32 bits -----|----- 32 bits -----|
Pointer:    |________________address______________w1|
Smi:        |____int32_value____|0000000000000000000|
複製代碼

不一樣於32位架構,在64位架構中V8能夠將32位用於Smi值有效載荷(payload)。如下各節將討論32位Smis對指針壓縮的影響。

壓縮標記值(tagged values)和新的堆佈局

使用指針壓縮,咱們的目標是以某種方式在64位架構中將兩種標記值轉換爲32位。咱們經過如下方式將指針調整爲32位:

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

這樣嚴格的限制是很是不幸的,可是Chrome中的V8已經將堆限制到2GB或4GB大小(具體限制到多少取決於設備),即便在64位架構上也是如此。其餘V8嵌入程序,例如Node.js可能須要更大的堆。若是咱們添加最大4GB的限制,就會讓這些嵌入V8的程序沒法使用指針壓縮。

如今的問題是如何更新堆佈局才能讓32位指針惟一標識V8對象。

簡單的堆內存佈局(Trivial heap layout)

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

簡單的堆內存佈局

可是很惋惜V8不能這樣作,由於Chrome的渲染進程可能須要在同一渲染器進程中建立多個V8的實例,例如對於Web/Service Workers。除此以外,用這個方案會致使全部的V8實例競爭相同的4GB地址空間從而致使全部的V8實例都受到4GB內存的限制。

堆內存佈局,v1

若是咱們將V8堆(heap)放在其餘地方的連續4GB地址空間,那麼一個從base開始的無符號32位偏移量將惟一標識一個指針。

堆內存佈局,開始base對齊

若是咱們確保base是4GB對齊(4-GB-aligned),則全部指針的高位32位都相同。

|----- 32 bits -----|----- 32 bits -----|
Pointer:    |________base_______|______offset_____w1|
複製代碼

經過將Smi的有效載荷(payload)限制爲31位並將其放在低32位,咱們還能夠壓縮Smis。基本上,使它和在32位架構中相似。

|----- 32 bits -----|----- 32 bits -----|
Smi:     |sssssssssssssssssss|____int31_value___0|
複製代碼

這裏 s 是Smi有效載荷的符號值。若是再有使用符號擴展表示,咱們就能夠僅用64位字的一位算數移位來壓縮和解壓Smis。

如今,咱們能夠看到指針和Smis的上半字(upper half-word)徹底由下半字定義。這樣,咱們就能夠只將後者存儲在內存中,從而將存儲標記值所需的內存減小一半。

|----- 32 bits -----|----- 32 bits -----|
Compressed pointer:                     |______offset_____w1|
Compressed Smi:                         |____int31_value___0|
複製代碼

假設base是4GB對齊的,則壓縮就是截斷:

uint64_t uncompressed_tagged;
uint32_t compressed_tagged = uint32_t(uncompressed_tagged);
複製代碼

可是解壓代碼要複雜一些。咱們須要區分符號擴展(sign-extending)Smi和零擴展(zero-extending)指針,以及是否要添加base。

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

若是將base不是放在4GB的開頭,而是中間,就能夠將壓縮值視爲從base開始的一個有符號32位偏移量。注意,整個保留再也不是4GB對齊(4-GB-aligned),可是base依然是對齊的。

堆內存佈局,中間base對齊

在這個新的佈局中,壓縮代碼和上面堆內存佈局v1中的相同。

然而解壓代碼變得更好了。如今對Smi和指針來講,符號擴展是相同的,惟一的分支在於若是是指針,須要添加base。

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是咱們過去使用的性能基準測試。儘管咱們在平常工做中再也不專一於提升性能峯值(improving peak performance),但咱們也不但願下降它,特別是一些像指針這樣對性能敏感的東西。Octane依然是完成這個任務的好的基準測試。

圖形顯示了在使用指針壓縮時Octane在x64架構上的得分。在圖中,線越高越好。紅色的線是未壓縮指針的x64構建,綠色的線是指針壓縮的版本。

Octane第一輪改進

在第一個方案中,咱們的迴歸差約爲35%。

Bump(1), +7%

首先咱們經過比較無分支解壓和有分支解壓,驗證了「無分支會更快」的假設。事實證實,咱們的假設是錯誤的,在x64上,有分支版本的速度提升了7%。這是很是大的不一樣!

下面看一下x64彙編

x64彙編

r13是base值的專用寄存器。注意,無分支代碼在這裏代碼量更多且須要的寄存器也更多。

在Arm64,咱們觀察到相同的現象——在強大的CPU上,有分支版本明顯更快(儘管這兩種狀況的代碼大小是同樣的)。

Arm64彙編

在低端Arm64設備上咱們發如今任一方向上幾乎沒什麼性能差別。

咱們的收穫是:在現代CPU中分支預測器很是的好,代碼的大小(code size)(尤爲是執行路徑的長度)對性能影響更大。

Bump(2), +2%

TurboFan是V8的優化編譯器,圍繞「Sea of Nodes」概念構建。簡單來講就是每個操做在graph中用一個Node表示(更詳細的解釋能夠查看這篇博客。這些節點有各類依賴,包括數據流和控制流。

有兩個對指針壓縮相當重要的操做:加載和存儲,由於它們將V8堆內存和管道(pipeline)的其他部分連起來。若是咱們每次從堆內存加載壓縮值的時候都解壓,而且在存儲以前對其壓縮,那麼管道(pipeline)就能夠像在全指針模式(full-pointer mode)下工做了。所以咱們在節點圖中添加了新的顯式操做——壓縮和解壓。

在某些狀況下解壓是不須要的,例如,若是一個壓縮值僅僅是從某個位置被加載而後存儲到新的位置。

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

Bump(3), +2%

在查看生成代碼時,咱們注意到解壓一個剛剛被加載的值會致使代碼的冗長:

movl rax, <mem>   // load
movlsxlq rax, rax // sign extend
複製代碼

一旦咱們修復了標誌擴展的問題,value就能夠直接從內存中加載。

movlsxlq rax, <mem>
複製代碼

咱們獲得了另外2%的改善。

Bump(4), +11%

TurboFan優化階段經過在graph上使用模式匹配工做:一旦一個sub-garph與一個特定模式匹配,就會被替換爲語義上等效(可是更好)的sub-graph或指令(instruction)。

嘗試匹配不成功並不會有明確的失敗提示。在graph中顯式的壓縮/解壓操做致使以前成功的模式匹配嘗試失敗,從而致使優化失敗且沒有提示。

「中斷」優化的其中一個例子是分配預配置(allocation preternuring)。一旦咱們更新匹配模式(pattern matching)使其可以匹配到新的壓縮 / 解壓 node,咱們就能夠獲得另外11%的改進。

Bump(5), +0.5%

在TurboFan中使用解壓去除(Decompression Elimination)咱們學到了不少。顯式的解壓 / 壓縮node方法具備如下特性:

優勢:

  • 很明顯咱們經過對sub-graphs的規範模式匹配能夠優化沒必要要的解壓。

可是,隨着咱們進一步的實施,咱們發現缺點:

  • 新的內部值的表示可能會致使轉換操做變的難以管理。除了現有的表示集(tagged Smi, tagged pointer, tagged any, word8, word16, word32, float32, float64, simd128),咱們還有壓縮指針,壓縮Smi,壓縮任何值(壓縮值能夠是指針或Smi)。

  • 現有的基於graph的模式匹配(pattern-matching)的優化並無生效,這致使了一些地方的回退(regressions)。儘管咱們找到並修復其中的問題,但TurboFan的複雜性仍在不斷增長。

  • 寄存器分配器(register allocator)對graph中的node數量愈來愈不滿意,而且常常生成錯誤的代碼。

  • 較大的node graph會減緩TurboFan優化階段,並增長編譯期間的內存消耗。

咱們決定回退一步,考慮在TurboFan中實現一種更簡單的指針壓縮方式。新的方法是刪除壓縮指針/Smi/任何表示,而後讓全部顯式的壓縮/解壓 node 隱藏在存儲和加載中,並假設咱們始終在加載以前壓縮,在存儲以前解壓。

咱們還在TurboFan中添加新的階段,該階段將替代「解壓消除(Decompression Elimination」。這個新的階段可以識別咱們何時不須要壓縮或解壓並相應地更新「加載和存儲」。這種方法顯著下降了TurboFan中指針壓縮的複雜性,提升了生成代碼的質量。

新的操做和初始時候同樣有效,而且又提升了0.5%的性能。

Bump(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之間的特殊case,而且能夠在解壓的時候無條件的添加base,即便是對Smis也能夠!咱們稱這個方法爲「Smi-corrupting」。

// New decompression implementation
int64_t uncompressed_tagged = base + int64_t(compressed_tagged);
複製代碼

因爲咱們不關注Smi的符號擴展(sign extending),所以這個改變容許咱們回到堆內存佈局v1。這是一個base指向4GB預留空間的開始位置。

就解壓代碼而言,這個改變將符號擴展(sign-extension)變爲零擴展(zero-extension),這也一樣簡單。可是這簡化了運行時(C++)端的工做。例如,例如地址空間區域保留代碼(查看一些細節實現部分)。

這是用於比較的彙編:

所以咱們更將8中全部的使用Smi的代碼塊調整爲新的壓縮方案,這給咱們另外2.5%的性能提高。

剩餘差距(Remaining gap)

剩餘的性能差距能夠用對64位構建的兩個優化來解釋,這些優化因爲與指針壓縮不兼容而禁用。

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

咱們回顧一下,Smis在64位架構全指針模式中看起來是這樣:

|----- 32 bits -----|----- 32 bits -----|
Smi:    |____int32_value____|0000000000000000000|
複製代碼

32-bit Smi有以下好處:

  • 它能夠有更大的整數範圍且不須要封裝成整數對象
  • 這樣的形式能夠在讀/寫時直接訪問32位值

因爲使用指針壓縮後會具備區分指針和Smis的bit,致使在32-bit壓縮指針中沒有空間,因此致使該優化沒法使用。若是咱們在64-bit版本中禁用32-bit smis,將會看到Octane值降低1%。

雙精度字段拆箱(雙精度 field unboxing) (8), -3%

譯者注:裝箱(boxing)是指編譯器自動將基本數據類型值轉換成對應的包裝類的對象,拆箱(unboxing)則是反過來。

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

想象一下下面這段代碼:

function Point(x, y) {
  this.x = x;
  this.y = y;
}
const p = new Point(3.1, 5.3);
複製代碼

通常來講,對象p在內存中的樣子以下:

關於更多存儲中的隱藏類,屬性和元素能夠閱讀此文

在64位架構中,雙精度值和指針的大小相同。因此若是咱們假設Point字段老是包含number值,則能夠將它們直接存儲在對象中。

若是某個字段致使假設不成立,例如執行下面這段代碼:

const q = new Point(2, 'ab');
複製代碼

y屬性的number值必須裝箱存儲(store boxed instead)。另外,若是某處的優化的代碼依賴此假設,則該優化必須捨棄。進行這些「字段類型」泛化的緣由是爲了儘可能減小經過同一構造函數建立的對象的Shapes(譯者注:在 JavaScript 程序中,多個對象具備相同的key,JS引擎會將這些key單獨存儲在一個地方,從而優化存儲,具體能夠查看[譯] JavaScript 引擎基礎:Shapes 和 Inline Caches)數量,反過來這對於具備穩定的性能是很必要的。

若是應用該優化,雙精度字段拆箱給咱們以下好處:

  • 經過對象指針提供對浮點數據的直接訪問,避免經過number對象進行額外的取消引用操做。
  • 容許咱們對緊湊循環(tight loops)生成更小更快的優化代碼從而能夠作大量的雙精度字段訪問。(例如在數字運算應用程序中)

啓用指針壓縮後,雙精度值再也不適合壓縮字段。然而,在將來咱們可能爲指針壓縮適配該優化。

注意,即便沒有雙精度字段拆箱優化(以與指針壓縮兼容的方式),也能夠經過將數據存儲在Float64 TypedArrays,甚至是使用Wasm重寫要求高吞吐量的數字運算代碼。

更多的優化(9),1%

最後,對TurboFan中的解壓消除優化進行微調又獲得另外1%的性能提高。

一些優化細節

爲了簡化將指針壓縮整合到現有代碼中,咱們決定在每次加載values的時候解壓而且在每次存儲的時壓縮它們。所以只是改變標誌值的存儲格式,而執行格式保持不變。

Native代碼端

爲了在解壓的時候生成有效的代碼,必須保證始終提供base值。幸運的是V8已經有一個專用的寄存器指向一個「根表(roots table)」,該表包含JavaScript和V8內部對象的引用,這些對象必須始終可用(例如:undefinednulltruefalse等)。該寄存器被稱爲「根寄存器」,它用來生成較小的,能夠共享的內部代碼

因此,咱們將根表放在V8堆保留區,根寄存器能夠同時有兩種用途:

  • 做爲根指針
  • 做爲解壓的base值

C++ 端

V8運行時經過C++類訪問在V8堆區的對象,從而提供對堆中存儲的數據的便捷訪問。請注意,V8對象比C++對象更相似於POD的結構。助手(helper)「view」類僅僅包含一個帶有相應標記值的uintptr_t字段。因爲view類是字大小的(word-size),所以咱們能夠將它按值傳遞,開銷爲零(這樣感謝現代C++編譯器)。

這裏是一個helper類的僞代碼:

// Hidden class
class Map {
  ...
  inline DescriptorArray instance_descriptors() const;
  ...
  // The actual tagged pointer value stored in the Map view object.
  cosnt 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);
}
複製代碼

爲了儘可能減小首次運行指針壓縮版本的所需的更改次數,咱們將解壓必須的base值的計算集成到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);
}
複製代碼

性能測量結果證明,在每次加載的時候計算base值會影響性能。緣由在於C++編譯器不知道,對於V8堆區的任何地址調用GetBaseForPointerCompression()的結果是相同的,所以編譯器沒法合併base值的計算。鑑於代碼包含多個指令和一個64位常量,這將致使代碼顯著膨脹。

爲了處理這個問題,咱們重用V8實例指針做爲解壓時用的base(記住,V8實例數據在堆區佈局中)。該指針一般在運行時函數中可用,因此咱們經過要求使用V8實例指針簡化getters代碼,並恢復來了性能:

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堆區內存比紐約時報要多,可是使用該優化後,使用堆內存狀況變得相反。這個不一樣能夠經過如下事實解釋:某些網站具備比其餘網站更多的標記值(Tagged values)。

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

結論

這一路上儘管沒有鳥語花香,可是值得度過。300+的提交後,指針壓縮讓V8擁有64位應用的性能,同時擁有32位的內存佔用。

咱們一直期待着性能的改進,並在流程中完成如下相關任務:

  • 改進生成彙編代碼的質量。咱們知道在某些狀況下咱們可以生成更少的代碼來提升性能。
  • 解決相關的性能降低,包括一個機制,該機制以指針壓縮友好的方式再次對doble字段拆箱。
  • 探索支持8~16G範圍內更大堆的想法。
相關文章
相關標籤/搜索