[譯]V8引擎中的排序

SmileSmith發表於2019-01-22

V8引擎中的排序

本文於 2018年9月28日,在V8開發者部落格中釋出

翻譯僅做學習交流,轉載請註明出處,商業用途請自行聯絡原文版權所有者

作者:Simon Zünd (@nimODota)

譯者:Smith

Array.prototype.sort 是V8引擎中最後一批採用JavaScript自託管實現的內建函式之一。在移植它的過程中,我們進行了不同演算法和實施策略的實驗,最終在V8引擎的7.0(Chrome 70)中釋出了排序穩定的版本。

背景

在JavaScript中排序並不簡單。這篇部落格介紹了排序演算法和JavaScript語言特性結合中的一些問題,並記錄了我們將V8轉移到穩定演算法並使效能更具可預測性的過程。

當比較不同的排序演算法時,我們將它們的最差效能和平均效能,看作是對記憶體訪問或比較次數的漸近增長(即“大O”符號)的約束。請注意,在動態語言(如JavaScript)中,比較操作通常比記憶體訪問更昂貴。這是因為在排序時比較兩個值通常涉及對使用者程式碼的呼叫。

譯註:客戶程式碼理解為排序中引擎外的程式碼,比如我們再用Array.prototype.sort一般會傳入回撥函式 [...].sort((a, b)=> a-b); 沒有回撥的情況也會有值處理,比如[1,'2'],在比較數字和字串前,Javascript會做型別轉換。

讓我們看一個簡單的示例:基於使用者提供的比較函式將一些數字按升序排序的。當a比b更小、相等、更大時,比較函式分別返回-1(或任何其他負值)、0、1(或任何其他正值)。不遵循此模式的比較函式則不相容,並且可能具有任意副作用,例如修改要排序的陣列。

const array = [4, 2, 5, 3, 1];

function compare(a, b) {
  // 任意程式碼, 例如 `array.push(1);`
  return a - b;
}

// 一個“典型的”sort呼叫
array.sort(compare);
複製程式碼

即使在下面這個不傳入回撥函式的示例中,也可能會發生對使用者程式碼的呼叫。比較函式,“預設”地,會在兩個要比較的值上呼叫toString,並對返回的兩個字串進行字典比較。

const array = [4, 2, 5, 3, 1];

array.push({
  toString() {
    // 任意程式碼, 例如 `array.push(1);`
    return '42';
  }
});

// 沒有比較函式的sort
array.sort();
複製程式碼

更有趣的是,屬性訪問器和原型鏈的相互影響

在本節內容中,我們拋開規範,開始嘗試“定義具體實現”的旅程。規範有一個完整的條件列表,當滿足時,允許引擎用它認為合適的方式,對物件/陣列進行排序 - 或者根本不對它進行排序。雖然排序的使用者必須遵循一些基本規則,但其他的一切幾乎都在空氣中(拋諸腦後,不管不顧的意思)。一方面,這使得引擎開發人員可以自由地嘗試不同的實現。另一方面,使用者期得到一些合理的表現,即使規範中並沒有要求。由於“合理的表現”並不總是直接明確定義的,導致事情變得更加複雜。

本節說明,在不同的引擎中,Array#sort在一些方面仍然表現出很大的差別。這些大多是一些邊緣的場景,如上所述,在這些場景中,並不總是明確“合理的表現”應該是什麼。我們強烈建議不要編寫這樣的程式碼,引擎不會優化它。

第一個示例顯示了,在不同JavaScript引擎中一個陣列的排序過程,其中包含一些記憶體訪問(即getter和setter)以及“日誌列印”。訪問器是第一個例子,展示“定義具體實現”對排序結果的影響:

const array = [0, 1, 2];

Object.defineProperty(array, '0', {
  get() { console.log('get 0'); return 0; },
  set(v) { console.log('set 0'); }
});

Object.defineProperty(array, '1', {
  get() { console.log('get 1'); return 1; },
  set(v) { console.log('set 1'); }
});

array.sort();
複製程式碼

下面是不同Javascript引擎中這段程式碼的輸出。請注意,這裡沒有“正確”或“錯誤”的答案 -- 因為規範中並沒有明確,而是將其留給不同引擎的實現!

get 0
get 1
set 0
set 1

// JavaScriptCore
get 0
get 1
get 0
get 0
get 1
get 1
set 0
set 1

// V8
get 0
get 0
get 1
get 1
get 1
get 0

#### SpiderMonkey
get 0
get 1
set 0
set 1
複製程式碼

下一個示例展示了原型鏈對排序結果的影響。為簡潔起見,我們不進行日誌列印。

const object = {
 1: 'd1',
 2: 'c1',
 3: 'b1',
 4: undefined,
 __proto__: {
   length: 10000,
   1: 'e2',
   10: 'a2',
   100: 'b2',
   1000: 'c2',
   2000: undefined,
   8000: 'd2',
   12000: 'XX',
   __proto__: {
     0: 'e3',
     1: 'd3',
     2: 'c3',
     3: 'b3',
     4: 'f3',
     5: 'a3',
     6: undefined,
   },
 },
};
Array.prototype.sort.call(object);
複製程式碼

下面是這個 物件 執行排序後的結果。同樣,這裡沒有所謂的正確答案。此示例僅展示了索引屬性與原型鏈之間的互動有多奇怪:

譯註:類似偽陣列

// Chakra
['a2', 'a3', 'b1', 'b2', 'c1', 'c2', 'd1', 'd2', 'e3', undefined, undefined, undefined]

// JavaScriptCore
['a2', 'a2', 'a3', 'b1', 'b2', 'b2', 'c1', 'c2', 'd1', 'd2', 'e3', undefined]

// V8
['a2', 'a3', 'b1', 'b2', 'c1', 'c2', 'd1', 'd2', 'e3', undefined, undefined, undefined]

// SpiderMonkey
['a2', 'a3', 'b1', 'b2', 'c1', 'c2', 'd1', 'd2', 'e3', undefined, undefined, undefined]
複製程式碼

V8在實際排序之前做了什麼

V8在實際排序之前有兩個預處理步驟。

首先,如果要排序的物件在原型鏈上有孔和元素,會將它們從原型鏈複製到物件本身。這樣在後續所有步驟中,我們都不需要再關注原型鏈。目前,V8只會對非標準的JSArrays進行這樣的處理,而其它引擎對於標準的JSArrays也會進行這樣的複製處理。

[譯]V8引擎中的排序

第二個預處理步驟是去孔(hole)。V8引擎會將排序範圍中的所有元素都移動到物件的開頭。之後移動 undefined。這在某種程度上其實是規範所要求的,因為規範要求引擎始終將undefined排序到最後。這樣nbbbb,undefined永遠都不會作為的引數去呼叫使用者提供的比較函式。在第二個預處理步驟之後,排序演算法只需要考慮 非undefined的,這可以減少實際排序的元素的數量。

[譯]V8引擎中的排序

歷史

Array.prototype.sort 和 TypedArray.prototype.sort 都基於同一種用JavaScript編寫的Quicksort實現。排序演算法本身非常簡單:基礎是一個Quicksort(快速排序),對於較短的陣列(長度<10)則降級為插入排序(Insertion Sort)。

當Quicksort在分治的處理中遞迴出長度小於10的子陣列時,也使用插入排序處理。因為插入排序對於較短的陣列更高效。這是因為Quicksort在分割槽後,需要遞迴呼叫兩次。每個這樣的遞迴呼叫都有建立(和丟棄)棧幀的開銷。

所以選擇合適的軸元素(pivot)對Quicksort的效能有著很大的影響。V8採用了兩條策略:

  • 找到陣列中的第一個,最後一個和“第三個”元素,然後選擇這三個元素的中間值作為pivot。對於較短陣列,“第三個”的的元素就是中間 元素。
  • 對於較長的陣列,就從中抽出一個小陣列進行排序,並將排序後中位數作為上述計算中的“第三個”元素。

Quicksort的優點之一是:它是就地排序,不需要太多的記憶體開銷。只有在處理大型陣列時,需要為選擇的樣本陣列分配記憶體,以及log(n)棧空間。它的缺點是:它不是穩定的排序演算法,並且在最壞情況下,時間複雜度會降級到O(n^2)。

介紹 V8 Torque

如果您是V8開發者部落格的愛好者,可能聽說過 CodeStubAssembler,或簡稱 CSA。CSA是V8的一個元件,它允許我們直接用C ++編寫低階別的TurboFan IR(TurboFan 中間層,見譯註),後來用TurboFan的後端(編譯器後端)將其合理結構的機器碼。

譯註:見CSA的連結,比較早了。TurboFan IR是V8自己搞的,相比於傳統的基於圖的中間層,特別做了些優化,想具體瞭解的話搜搜大牛的文章吧

CSA被大量應用於為JavaScript內建函式編寫所謂的“快速路徑”。內建的“快速路徑”版本通常檢查某些特別的條件是否成立(例如原型鏈上沒有元素,沒有訪問器等),然後使用更快,更特殊優化的操作來實現內建函式的功能。這可以使函式執行時間比通用版本快一個數量級。

CSA的缺點是它確實可以被認為是組合語言。流程控制使用明確的 labelgoto進行建模,這使得在CSA中實現複雜演算法時,程式碼會難以閱讀且容易出錯。

然後是V8 Torque。Torque是一種領域專用語言,具有類似TypeScript的語法,目前使用CSA作為其唯一的編譯目標。Torque允許開發者使用與CSA幾乎相同層次的流程控制操作,同時提供更高階別的構造,例如while和for迴圈。此外,它是強型別的,並且將來還會包含類似自動越界這樣的安全檢查,為V8引擎的工程師提供更強大的保障。

用V8 Torque重寫的第一個重要的內建函式是 TypedArray#sortDataview。這兩者的重寫都有另外的目的,即向Torque開發人員反饋所需要的語言功能,以及使用哪些模式會可以更高效地編寫內建函式。在撰寫本文時,多個JSArray的內建函式和對應的自託管的JavaScript後降級實現,已經遷移至Torque(例如,Array#unshift),其餘的則被完全重寫(例如,Array#splice和Array#reverse)。

Array # sort 遷移到 Torque

最初的Array#sort Torque版本或多或少可以說就是JavaScript實現的直接搬運。唯一的區別是,對較長的陣列不進行小陣列取樣,而是隨機選擇陣列中的某個元素作為軸元素選擇中的“第三個”元素。

這種方式執行得相當不錯,但由於它仍然使用Quicksort,因此 Array#sort仍然是不穩定。請求穩定版本的Array#sort 是V8的bug記錄器中最古老的工單之一。接下來嘗試用Timsort替代,在這個嘗試中我們得到了多種好處。首先,我們喜歡它是一個穩定的演算法,並提供一些很好的演算法保證(見下一節)。其次,Torque仍然是一個正在開發中的專案,在Torque中用Timsort實現複雜的內建函式,例如“Array#sort”,可以給Torque語言的本身帶來很多可操作性的建議。

TimSort

最早由蒂姆·彼得斯(Tim Peters)於2002年開發的Timsort,可以被認為是自適應的穩定的歸併排序(Mergesort)的變種。其實現細節相當複雜,最好去參閱作者本人的說明維基百科,基礎概念應該很容易理解。雖然Mergesort使用遞迴的方式,但Timsort是以迭代進行。Timsort從左到右迭代一個陣列,並尋找所謂的_runs_。一個run可以認為是已經排序的小陣列,也包括以逆向排序的,因為這些陣列可以簡單地翻轉(reverse)就成為一個run。在排序過程開始時,演算法會根據輸入陣列的長度,確定一個run的最小長度。如果Timsort無法在陣列中找到滿足這個最小長度的run,則使用插入排序(Insertion Sort)“人為地生成”一個run。

找到的 runs在一個棧中追蹤,這個棧會記錄起始的索引位置和每個run的長度。棧上的run會逐漸合併在一起,直到只剩下一個排序好的run。在確定合併哪些run時,Timsort會試圖保持兩方面的平衡。一方面,您希望儘早嘗試合併,因為這些run的資料很可能已經在快取中,另一方面,您希望儘可能晚地合併,以利用資料中可能出現的某些特徵。為了實現這個平衡,Timsort遵循保兩個原則。假設ABC是三個最頂級的runs:

  • |C| > |B| + |A|
  • |B| > |A|

譯註:這裡的大於指長度大於

[譯]V8引擎中的排序

在上圖的例子中,因為| A |> | B |,所以B被合併到了它前後兩個runs(A、C)中較小的一個。請注意,Timsort僅合併連續的run,這是維持演算法穩定性所必需的,否則大小相等元素會在run中轉移。此外,第一個原則確保了run的長度,最慢也會以斐波那契(Fibonacci)數列增長,這樣當我們知道陣列的最大邊界時,runs棧大小的上下界也可以確定了。

現在可以看出,對於已經排序好的陣列,會以O(n)的時間內完成排序,因為這樣的陣列將只產生單個run,不需要合併操作。最壞的情況是O(n log n)。這樣的演算法效能引數,以及Timsort天生的穩定性是我們最終選擇Timsort而非Quicksort的幾個原因。

在 Torque 中實現 Timsort

內建函式通常具有不同的程式碼版本,在執行時(runtime)會根據各種變數選擇合適的程式碼版本。而通用的版本則可以處理任何型別的物件,無論它是一個JSProxy,有攔截器,還是在查詢/設定屬性時有原型鏈查詢。

在大多數情況下,通用的路徑版本相當慢,因為它需要考慮所有的可能性。但是如果我們事先知道要排序的物件是一個只包含Smis的簡單JSArray,所有這些昂貴的[[Get]][[Set]]操作都可以被簡單地替換為FixedArray的Loads和Stores。主要的區別在於ElementsKind

譯註:ElemenKind簡單來講,就是一個陣列的元素型別,比如[1, 2]是ElementKind是Int,[1, 2.1]則是Double

現在問題變成了如何實現快速路徑。除了基於ElementsKind的不同更改訪問元素的方式之外,核心演算法對所有場景保持相同。一種實現方案是:對每個操作都分配合適的“訪問器(accessor)”。想象一下每個“載入”/“儲存”(“load”/”store”)都有一個開關,我們通過開關來選擇不同快速路徑的分支。

另一個實現方案(這是最開始嘗試的方式)是為對每個快速路徑都複製整個內建函式並內聯合適的載入/儲存方法(load/store)。但這種方式對於Timsort來說是不可行的,因為它是一個很大的內建函式,複製每個快速路徑總共需要106 KB的空間,這對於單個內建函式來說太過分了。

最終的方案略有不同。每個快速路徑的每個載入/儲存方法(load/store)都被放入其自己的“迷你內建函式”中。請參閱程式碼示例,其中展示了針對“FixedDoubleArray”的“載入”(load)操作。

Load<FastDoubleElements>(
    context: Context, sortState: FixedArray, elements: HeapObject,
    index: Smi): Object {
  try {
    const elems: FixedDoubleArray = UnsafeCast<FixedDoubleArray>(elements);
    const value: float64 =
        LoadDoubleWithHoleCheck(elems, index) otherwise Bailout;
    return AllocateHeapNumberWithValue(value);
  }
  label Bailout {
    // 預處理步驟中,通過把所有元素移到陣列最前的方式 已經移除了所有的孔
    // 這時如果找到了孔,說明 cmp 函式或 ToString 改變了陣列
    return Failure(sortState);
  }
}
複製程式碼

相比之下,最通用的“載入”操作(load)只是對GetProperty的呼叫。相比於上述版本生成了高效且快速的機器程式碼來載入和轉換Number,GetProperty只是對另一個內建函式的呼叫,這之中可能涉及對原型鏈的查詢或訪問器函式的呼叫。

builtin Load<ElementsAccessor : type>(
    context: Context, sortState: FixedArray, elements: HeapObject,
    index: Smi): Object {
  return GetProperty(context, elements, index);
}
複製程式碼

這樣一來,快速路徑就變成一組函式指標。這意味著我們只需要核心演算法的一個副本,同時預先設定所有相關函式的指標。雖然這大大減少了所需的程式碼空間(低至20k),但卻以每個訪問點的使用不同的間接分支做為減少的代價。這種情況在最近引入嵌入式內建函式的變更後加劇了。

排序狀態

[譯]V8引擎中的排序

上圖顯示了“排序狀態”。它是一個FixedArray,展示了排序時涉及到的所有內容。每次呼叫Array#sort時,都會分配這種排序狀態。期中04到07是上面討論的構成快速路徑的一組函式指標。

每次在使用者的JavaScript程式碼返回(return)時都會呼叫內建函式“check”,以檢查我們是否可以繼續使用當前的快速路徑。它使用“initial receiver map”和“initial receiver length”來做檢查。如果使用者程式碼修改了當前物件,我們只需放棄排序執行,將所有指標重置為最通用的版本並重新啟動排序的過程。08中的“救助狀態”作為重置的訊號。 “compare”可以指向兩個不同的內建函式。一個呼叫使用者提供的比較函式,另一個上面說的預設比較(對兩個引數執行toString,然後進行字典比較)。

其餘欄位(14:Fast path ID除外)都是Timsort所特有的。執行時的run棧(如上所述)初始化長度為85,這足以對長度為264的陣列進行排序。而用於合併執行臨時陣列的長度,會根據執行時的需要所增大,但絕不超過n / 2,其中n是輸入陣列的長度。

效能妥協

將 Array # sort 的自託管JavaScript實現轉移到Torque需要進行一些效能權衡。由於Array#sort是用Torque編寫的,它現在是一段靜態編譯的程式碼,這意味著我們仍然可以為預設特定的 ElementsKind`s構建快速路徑,但它永遠不會比 TurboFan 高度優化的程式碼更快,因為TurboFan可以利用型別反饋進行優化。另一方面,如果程式碼沒有足夠熱以保證JIT編譯或者呼叫點是復態(megamorphic)的,我們就會被卡在直譯器或慢速/通用的版本。自託管JavaScript實現版本中的解析,編譯和可能的優化過程中所產生的開銷,在Torque實現版本中也都不需要了。

雖然Torque的實現方案無法讓排序達到相同峰值效能,但它確實避免了效能斷崖。結果是排序的效能比以前更容易預測。請注意,Torque還在不停的迭代中,除了編譯到CSA之外,它可能會在未來支援編譯到TurboFan,允許JIT去編譯用Torque編寫的程式碼。

微基準測試

在我們著手開發Array#sort之前,我們新增了一系列不同的微基準測試,以便更好地瞭解重寫對效能的影響。第一個圖顯示了使用使用者提供的比較函式對各種ElementsKinds進行排序的“正常”用例。

請注意,在這些情況下,JIT編譯器會做很多工作,因為排序過程幾乎就是我們(引擎)所處理的。雖然這樣允許我們在編譯器內聯JavaScript版本中的比較函式,但在Torque中也會有內建函式到JavaScript的額外呼叫開銷。不過,我們新的Timsort幾乎在所有情況下都表現得更好。

[譯]V8引擎中的排序

下一個圖表顯示了:在處理已完全排序的陣列,或者具有已單向排序的子序列的陣列時,Timsort對效能的影響。下圖中採用Quicksort作為基線,展示了Timsort的加速比(在“DownDown”的情況下高達17倍,這個場景中陣列由兩個反向排序的子序列組成)。可以看出,除了在隨機資料的情況下,Timsort在其它所有情況下都表現得更好,即使我們排序的物件是PACKED_SMI_ELEMENTS(Quicksort在上圖的微基準測試中對這種物件排序時,效能勝過了Timsort)。

[譯]V8引擎中的排序

Web 工具基準測試

Web Tooling Benchmark是在Web開發人員常用工具的JS環境載體(如Babel和TypeScript)中進行的測試。下圖採用JavaScript Quicksort作為基線,展示了Timsort的速度提升。除了 chai 我們在幾乎所有測試中獲得了相同的效能。

chai的基準測試中將三分之一的時間用於一個比較函式中(字串長度計算)。基準測試用的是chai自身的測試套件。由於資料的原因,Timsort在這種情況下需要更多的比較,這對整體的執行有著很大的影響,因為大部分時間都花在了特定的比較函式中。

[譯]V8引擎中的排序

記憶體影響

在瀏覽大約50個站點(分別在移動裝置和桌面裝置上)分析V8堆的快照時,沒有顯示出任何記憶體消耗的增加或減少。一方面這很讓人意外:從Quicksort到Timsort的轉換引入了對合並run操作所需要的臨時陣列的空間,這應該會比Quicksort用於取樣的臨時陣列大得多。另一方面,其實這些臨時陣列的存續時間非常短暫(僅在sort呼叫的持續時間內)並且可以在V8的新空間中非常快速地建立和刪除。

結論

總的來說,對於在Torque中實現的Timsort的演算法屬性和可預測的效能,讓我們感覺很好。Timsort從V8 v7.0和Chrome 70開始生效。快樂排序吧!

作者: Simon Zünd (@nimODota), consistent comparator.

相關文章