怎樣提升 Spark 中排序的效能?

drowzju發表於2016-07-20

Cloudera和Intel的工程師們正在就提升Spark shuffle過程的可擴充套件性和可靠性方面展開合作。下面是該方案設計上的細節:

像MapReduce和Apache Spark(Apache Hadoop的下一代資料處理引擎)這樣的計算引擎,和高度並行系統之間的主要區別就是對”多對多”操作的支援。作為分散式引擎,MapReduce和Spark要對分割槽會跨越叢集的資料集合的子分片進行操作。很多操作一次只處理集中的資料,可以完全在分割槽內部完成。多對多的操作必須把整個資料集視作整體;每個輸出記錄的內容可能依賴於來自於不同分割槽的記錄。對Spark而言,groupByKey, sorbByKey, 以及 reduceByKey  就是常見的這類操作。

對分散式計算引擎而言,”shuffle”指的是多對多操作中資料的重新分割槽和匯聚。很容易理解,我們在實際生產環境的Spark部署中觀察到的大多的效能、可伸縮性和可靠性問題,都出現在shuffle過程之中。

Cloudera和Intel的工程師們正在就提升Spark的shuffle過程展開合作,以保證可以更快、更可靠地處理大量資料集。儘管Spark在許多方面都擁有對MapReduce的優勢,但是在可伸縮性和可靠性方面它還是追趕者。我們從經過實戰檢驗的MapReduce shuffle的實現借用了一些概念,用於改進輸出排序資料的shuffle操作。

本文中,我們將縱覽當前Spark shuffle實現的工作機制,和我們所推薦的改變,以及這些改變是如何提升了效能。此工作目前還在進行之中:SPARK-2926.

當前事務的狀態

一個shuffle過程涉及兩個任務集合:產生shuffle資料的階段所包含的任務,以及消費這些資料的階段所包含的任務。出於歷史原因,產生shuffle資料的任務被稱作”map任務”, 而讀取shuffle資料的任務被稱為”reduce任務”。這兩種角色是相對任務中的具體shuffle過程而言的。一個任務可能在一次shuffle中是在讀資料,是reduce任務;而它在下一次shuffle中又產生了被下一階段所消費的資料,它又是map任務。

MapReduce和Spark的shuffle過程採用了”拉”的模型。每個map任務在本地磁碟寫入資料,然後reduce任務發起遠端請求獲取資料。因為shuffle是多對多操作的結果,任一map任務都可能有一組會被任意reduce任務使用的記錄。shuffle在map端的工作就是要以這樣的方式寫入記錄:把所有的需要被同一reduce任務訪問的記錄排列在一起,以保證它們能被更容易地獲取。

Spark的原生shuffle(基於HASH的shuffle)通過在map任務中為每一個reduce任務開啟一個檔案來實現這個目標。這個方案有好處就是簡單,不過帶來了很多問題。比如,Spark 必須要麼使用大量記憶體快取每個檔案,要麼帶來大量的隨機磁碟I/O。另一個問題是,如果M和R是shuffle過程中map和reduce任務的數目,基於HASH的shuffle 需要總共M*R箇中間檔案。Shuffle consolidation work把中間檔案的數目減少到了 C * R個,C是map任務同時可以投入執行的數目。不過即使如此,使用者在執行擁有相當數目的reducer 的作業時 ,還是會經常遇到”Too many open files”的ulimit限制。


Single map task in hash-based shuffle


Single map task in sort-based shuffle

為了進一步改進shuffle的可伸縮性和效能,從1.1版本開始,Spark引入了一種”基於排序的shuffle”實現,和MapReduce的map端實現很相似。在這種實現中, 每個map任務的輸出記錄會儲存在記憶體中直到不能再填入。此時記錄會被按照它們所要被投遞的reduce任務進行排序並溢寫到一個單獨的檔案。若此過程在一個任務中發生多次,所有溢寫的檔案在後續會被合併。

在reduce端,一組執行緒負責獲取遠端的map輸出塊。每當有塊到達,記錄資訊會被反序列化,並被傳入相應的多對多操作結果的資料結構中。對groupByKey, reduceByKey, 和aggregateByKey這樣的匯聚操作,記錄會被傳入一個ExternalAppendOnlyMap,它本質上是個HASH 對映,超過記憶體用量時也會溢寫到磁碟。對類似於sortByKey這樣的排序操作,記錄會被傳入ExternalSorter,它會對資料排序,很可能會溢寫到磁碟,並返回一個針對資料做有序迭代的迭代器。

全排序shuffle

上述方案有兩個缺陷:

  • 每個Reduce任務同時在記憶體中持有太多反序列化的記錄。大量Java物件給JVM的垃圾回收帶來壓力,可能導致業務變慢或中止。比序列化的版本多佔用了記憶體,還意味著Spark得更早更頻繁地進行溢寫,帶來更多的磁碟I/O。此外,很難對反序列化物件的記憶體佔用情況做出百分百正確的判定,所以保持越多的物件,就帶來越多的記憶體溢位錯誤。
  • 當需要在分割槽進行排序操作時,我們面對的是對同樣的資料排序兩次:第一次是mapper中進行分割槽時,第二次是reducer中進行按key操作。

我們的修改在map端的分割槽過程中對記錄按key排序過了,所以在reduce端,我們只需要把從各個map 任務過來的排序之後的塊合併。我們把塊在記憶體中以序列化的格式儲存,在合併時一次反序列化一個記錄。因此任何時刻反序列化記錄的最大數目都是我們要合併到一起的塊的數目。


Single map task in full sort-based shuffle

單獨一個reduce任務就可以從上千個map任務接收塊。為了讓這個多路合併的過程更高效,特別是在記憶體不足以放入資料的情況下,我們引入了tiered merger。當我們需要合併大量磁碟上的塊,tiered merer引導合併塊的子集,以最小化磁碟定址。tiered merger 對ExternalAppendOnlyMap和ExternalSorter的merge步驟也適用,不過我們還沒有修改這兩處以利用tiered mereger。

高效能合併

對每個任務,都有一組執行緒用於並行獲取shuffle的資料。針對每個任務分配一個48M大小的記憶體池用來做獲取資料的著陸點。

我們引入了SortShuffleReader,它負責操作塊資料,並向使用者程式碼提供一個基於[Key, Value]對的迭代器。

Spark維護了一個主shuffle記憶體區,所有任務共享,預設大小是整個executor堆的20%。當有資料塊到來,SortShuffleReader嘗試從此主記憶體區為資料塊獲取記憶體。序列化的塊往記憶體中填充,直到嘗試申請記憶體失敗。此時我們需要溢寫資料到磁碟以釋放空間。SortShuffleReader合併所有(確切說不算所有;有時候最好只溢寫一部分)記憶體中的塊到一個單一的有序的磁碟檔案。當磁碟上資料塊開始堆積,一個後臺執行緒會進行監控,並在有必要時把它們合併為大的有序磁碟資料塊。”最終合併”把最終磁碟資料塊的集合和所有還在記憶體中的塊合併到一起,向使用者程式碼返回一個迭代器。

我們如何判斷何時需要進行中間的磁碟到磁碟的合併?spark.shuffle.maxMergeFactor(預設100)屬性控制著單次合併中磁碟資料塊的最大數目。當磁碟資料塊的數目超過了這個限制,後臺執行緒會執行一次合併過程以降低此數目(不過不是立刻;程式碼有更多細節)。要決定合併多少塊,此執行緒會首先最小化它進行的合併次數,然後,在此數目內,嘗試儘可能少地合併塊。因此,如果spark.shuffle.maxMergeFactor是100,而磁碟資料塊是110,執行緒只會把11個塊合併到一起,即最終磁碟塊的數目是100。合併多點塊會需要額外的合併過程,合併多的塊會導致不必要的磁碟I/O。



maxMergeWidth=4時的Tiered merge。每個矩形都是一個磁碟檔案。三個檔案被合併成一個檔案,最終4個檔案被合併為一個迭代器,供下次操作使用。

和sortByKey的效能對比

我們使用SparkPerf的 sort-by-key 工作負載進行測試,以評估我們所做修改的效能影響。我們選擇了兩種不同大小的資料集用於比較在記憶體足以或不足以儲存所有shuffle時,我們所做修改的效能收益。

Spark的sortBykey轉換導致兩種作業和三個階段。

  • 取樣階段:對資料取樣,建立範圍分割槽函式,它會產生均勻的分割槽;
  • “Map”階段: 把資料寫入目的shuffle桶,供reduce階段使用;
  • “Reduce”階段: 獲得相關的shuffle輸出,並針對資料集的特定分割槽進行合併/排序

檢查程式在一個6節點叢集上實施。每個executor有24個核,以及36GB的記憶體。大的資料集有200億條記錄,經壓縮後在HDFS上佔用了409.8GB的空間。曉得資料集有20億條記錄,壓縮後在HDFS上佔用了15.9GB的空間。每條記錄是兩個10字元的key-value對。每個用例中,排序操作都有超過1000個分割槽。每個階段和整個作業的執行時間的圖表展示如下:


大的資料集(數值低較好)


小的資料集(數值低較好)

取樣階段是一樣的,因為不涉及shuffle過程。map階段,因為我們的改進現在會在每個分割槽內對資料按key排序,所以階段執行時間有增長(大的資料集增長了37%,小的資料集增長了27%)。儘管如此,reduce階段還是彌補了map超出的時間且有盈餘,因為reduce現在只需要把排序之後的資料進行合併。對大小兩個資料集,reduce階段的持續時間都減少了超過66%,為大的資料集總共帶來了超過27%的提升,為小的資料集帶來了17%的整體提升。

下一步?

SPARK-2926 是對Spark的shuffle機制的一些改進計劃之一。要特別說明一下,有很多方式可以讓shuffle更好的管理記憶體:

  • SPARK-4550 跟蹤:把記憶體快取的map輸出資料作為原始位元組儲存,而不是Java物件。這樣做可以讓map的輸出資料佔據更少記憶體空間,也就減少了溢寫。同時原始位元組的比較速度更快。
  • SPARK-4452 跟蹤:更小心地為不同的shuffle資料結構分配記憶體,還有更快地返還不再需要的記憶體。
  • SPARK-3461 跟蹤:對傳入的groupBy或join的資料按照特定key去處理值,而不是一次性把所有內容載入進記憶體。

相關文章