Spark Shuffle實現

白喬發表於2015-03-06

Apache Spark探祕:Spark Shuffle實現

http://dongxicheng.org/framework-on-yarn/apache-spark-shuffle-details/

對於大資料計算框架而言,Shuffle階段的設計優劣是決定效能好壞的關鍵因素之一。本文將介紹目前Spark的shuffle實現,並將之與MapReduce進行簡單對比。本文的介紹順序是:shuffle基本概念,MapReduce Shuffle發展史以及Spark Shuffle發展史。

(1)  shuffle基本概念與常見實現方式

shuffle,是一個運算元,表達的是多對多的依賴關係,在類MapReduce計算框架中,是連線Map階段和Reduce階段的紐帶,即每個Reduce Task從每個Map Task產生數的據中讀取一片資料,極限情況下可能觸發M*R個資料拷貝通道(M是Map Task數目,R是Reduce Task數目)。通常shuffle分為兩部分:Map階段的資料準備和Reduce階段的資料拷貝。首先,Map階段需根據Reduce階段的Task數量決定每個Map Task輸出的資料分片數目,有多種方式存放這些資料分片:

1) 儲存在記憶體中或者磁碟上(Spark和MapReduce都存放在磁碟上);

2) 每個分片一個檔案(現在Spark採用的方式,若干年前MapReduce採用的方式),或者所有分片放到一個資料檔案中,外加一個索引檔案記錄每個分片在資料檔案中的偏移量(現在MapReduce採用的方式)。

在Map端,不同的資料存放方式各有優缺點和適用場景。一般而言,shuffle在Map端的資料要儲存到磁碟上,以防止容錯觸發重算帶來的龐大開銷(如果儲存到Reduce端記憶體中,一旦Reduce Task掛掉了,所有Map Task需要重算)。但資料在磁碟上存放方式有多種可選方案,在MapReduce前期設計中,採用了現在Spark的方案(目前一直在改進),每個Map Task為每個Reduce Task產生一個檔案,該檔案只儲存特定Reduce Task需處理的資料,這樣會產生M*R個檔案,如果M和R非常龐大,比如均為1000,則會產生100w個檔案,產生和讀取這些檔案會產生大量的隨機IO,效率非常低下。解決這個問題的一種直觀方法是減少檔案數目,常用的方法有:1) 將一個節點上所有Map產生的檔案合併成一個大檔案(MapReduce現在採用的方案),2) 每個節點產生{(slot數目)*R}個檔案(Spark優化後的方案)。對後面這種方案簡單解釋一下:不管是MapReduce 1.0還是Spark,每個節點的資源會被抽象成若干個slot,由於一個Task佔用一個slot,因此slot數目可看成是最多同時執行的Task數目。如果一個Job的Task數目非常多,限於slot數目有限,可能需要執行若干輪。這樣,只需要由第一輪產生{(slot數目)*R}個檔案,後續幾輪產生的資料追加到這些檔案末尾即可。因此,後一種方案可減少大作業產生的檔案數目。

在Reduce端,各個Task會併發啟動多個執行緒同時從多個Map Task端拉取資料。由於Reduce階段的主要任務是對資料進行按組規約。也就是說,需要將資料分成若干組,以便以組為單位進行處理。大家知道,分組的方式非常多,常見的有:Map/HashTable(key相同的,放到同一個value list中)和Sort(按key進行排序,key相同的一組,經排序後會挨在一起),這兩種方式各有優缺點,第一種複雜度低,效率高,但是需要將資料全部放到記憶體中,第二種方案複雜度高,但能夠藉助磁碟(外部排序)處理龐大的資料集。Spark前期採用了第一種方案,而在最新的版本中加入了第二種方案, MapReduce則從一開始就選用了基於sort的方案。

(2) MapReduce Shuffle發展史

【階段1】:MapReduce Shuffle的發展也並不是一馬平川的,剛開始(0.10.0版本之前)採用了“每個Map Task產生R個檔案”的方案,前面提到,該方案會產生大量的隨機讀寫IO,對於大資料處理而言,非常不利。

【階段2】:為了避免Map Task產生大量檔案,HADOOP-331嘗試對該方案進行優化,優化方法:為每個Map Task提供一個環形buffer,一旦buffer滿了後,則將記憶體資料spill到磁碟上(外加一個索引檔案,儲存每個partition的偏移量),最終合併產生的這些spill檔案,同時建立一個索引檔案,儲存每個partition的偏移量。

(階段2):這個階段並沒有對shuffle架構做調成,只是對shuffle的環形buffer進行了優化。在Hadoop 2.0版本之前,對MapReduce作業進行引數調優時,Map階段的buffer調優非常複雜的,涉及到多個引數,這是由於buffer被切分成兩部分使用:一部分儲存索引(比如parition、key和value偏移量和長度),一部分儲存實際的資料,這兩段buffer均會影響spill檔案數目,因此,需要根據資料特點對多個引數進行調優,非常繁瑣。而MAPREDUCE-64則解決了該問題,該方案讓索引和資料共享一個環形緩衝區,不再將其分成兩部分獨立使用,這樣只需設定一個引數控制spill頻率。

【階段3(進行中)】:目前shuffle被當做一個子階段被嵌到Reduce階段中的。由於MapReduce模型中,Map Task和Reduce Task可以同時執行,因此一個作業前期啟動的Reduce Task將一直處於shuffle階段,直到所有Map Task執行完成,而在這個過程中,Reduce Task佔用著資源,但這部分資源利用率非常低,基本上只使用了IO資源。為了提高資源利用率,一種非常好的方法是將shuffle從Reduce階段中獨立處理,變成一個獨立的階段/服務,由專門的shuffler service負責資料拷貝,目前百度已經實現了該功能(準備開源?),且收益明顯,具體參考:MAPREDUCE-2354

(3) Spark Shuffle發展史

目前看來,Spark Shuffle的發展史與MapReduce發展史非常類似。初期Spark在Map階段採用了“每個Map Task產生R個檔案”的方法,在Reduce階段採用了map分組方法,但隨Spark變得流行,使用者逐漸發現這種方案在處理大資料時存在嚴重瓶頸問題,因此嘗試對Spark進行優化和改進,相關連結有:External Sorting for Aggregator and CoGroupedRDDs,“Optimizing Shuffle Performance in Spark”,“Consolidating Shuffle Files in Spark”,優化動機和思路與MapReduce非常類似。

Spark在前期設計中過多依賴於記憶體,使得一些執行在MapReduce之上的大作業難以直接執行在Spark之上(可能遇到OOM問題)。目前Spark在處理大資料集方面尚不完善,使用者需根據作業特點選擇性的將一部分作業遷移到Spark上,而不是整體遷移。隨著Spark的完善,很多內部關鍵模組的設計思路將變得與MapReduce升級版Tez非常類似。

【其他參考資料】

Spark原始碼分析 – Shuffle

詳細探究Spark的shuffle實現

原創文章,轉載請註明: 轉載自董的部落格

本文連結地址: http://dongxicheng.org/framework-on-yarn/apache-spark-shuffle-details/



詳細探究Spark的shuffle實現


http://jerryshao.me/architecture/2014/01/04/spark-shuffle-detail-investigation/

Background

在MapReduce框架中,shuffle是連線Map和Reduce之間的橋樑,Map的輸出要用到Reduce中必須經過shuffle這個環節,shuffle的效能高低直接影響了整個程式的效能和吞吐量。Spark作為MapReduce框架的一種實現,自然也實現了shuffle的邏輯,本文就深入研究Spark的shuffle是如何實現的,有什麼優缺點,與Hadoop MapReduce的shuffle有什麼不同。

Shuffle

Shuffle是MapReduce框架中的一個特定的phase,介於Map phase和Reduce phase之間,當Map的輸出結果要被Reduce使用時,輸出結果需要按key雜湊,並且分發到每一個Reducer上去,這個過程就是shuffle。由於shuffle涉及到了磁碟的讀寫和網路的傳輸,因此shuffle效能的高低直接影響到了整個程式的執行效率。

下面這幅圖清晰地描述了MapReduce演算法的整個流程,其中shuffle phase是介於Map phase和Reduce phase之間。

mapreduce running process

概念上shuffle就是一個溝通資料連線的橋樑,那麼實際上shuffle這一部分是如何實現的的呢,下面我們就以Spark為例講一下shuffle在Spark中的實現。

Spark Shuffle進化史

先以圖為例簡單描述一下Spark中shuffle的整一個流程:

spark shuffle process

  • 首先每一個Mapper會根據Reducer的數量建立出相應的bucket,bucket的數量是M×R,其中M是Map的個數,R是Reduce的個數。
  • 其次Mapper產生的結果會根據設定的partition演算法填充到每個bucket中去。這裡的partition演算法是可以自定義的,當然預設的演算法是根據key雜湊到不同的bucket中去。
  • 當Reducer啟動時,它會根據自己task的id和所依賴的Mapper的id從遠端或是本地的block manager中取得相應的bucket作為Reducer的輸入進行處理。

這裡的bucket是一個抽象概念,在實現中每個bucket可以對應一個檔案,可以對應檔案的一部分或是其他等。

接下來我們分別從shuffle writeshuffle fetch這兩塊來講述一下Spark的shuffle進化史。

Shuffle Write

在Spark 0.6和0.7的版本中,對於shuffle資料的儲存是以檔案的方式儲存在block manager中,與rdd.persist(StorageLevel.DISk_ONLY)採取相同的策略,可以參看:

  1. override def run(attemptId: Long): MapStatus = {
  2. val numOutputSplits = dep.partitioner.numPartitions
  3. ...
  4. // Partition the map output.
  5. val buckets = Array.fill(numOutputSplits)(new ArrayBuffer[(Any, Any)])
  6. for (elem <- rdd.iterator(split, taskContext)) {
  7. val pair = elem.asInstanceOf[(Any, Any)]
  8. val bucketId = dep.partitioner.getPartition(pair._1)
  9. buckets(bucketId) += pair
  10. }
  11. ...
  12. val blockManager = SparkEnv.get.blockManager
  13. for (i <- 0 until numOutputSplits) {
  14. val blockId = "shuffle_" + dep.shuffleId + "_" + partition + "_" + i
  15. // Get a Scala iterator from Java map
  16. val iter: Iterator[(Any, Any)] = buckets(i).iterator
  17. val size = blockManager.put(blockId, iter, StorageLevel.DISK_ONLY, false)
  18. totalBytes += size
  19. }
  20. ...
  21. }

我已經將一些干擾程式碼刪去。可以看到Spark在每一個Mapper中為每個Reducer建立一個bucket,並將RDD計算結果放進bucket中。需要注意的是每個bucket是一個ArrayBuffer,也就是說Map的輸出結果是會先儲存在記憶體。

接著Spark會將ArrayBuffer中的Map輸出結果寫入block manager所管理的磁碟中,這裡檔案的命名方式為:shuffle_ + shuffle_id + "_" + map partition id + "_" + shuffle partition id

早期的shuffle write有兩個比較大的問題:

  1. Map的輸出必須先全部儲存到記憶體中,然後寫入磁碟。這對記憶體是一個非常大的開銷,當記憶體不足以儲存所有的Map output時就會出現OOM。
  2. 每一個Mapper都會產生Reducer number個shuffle檔案,如果Mapper個數是1k,Reducer個數也是1k,那麼就會產生1M個shuffle檔案,這對於檔案系統是一個非常大的負擔。同時在shuffle資料量不大而shuffle檔案又非常多的情況下,隨機寫也會嚴重降低IO的效能。

在Spark 0.8版本中,shuffle write採用了與RDD block write不同的方式,同時也為shuffle write單獨建立了ShuffleBlockManager,部分解決了0.6和0.7版本中遇到的問題。

首先我們來看一下Spark 0.8的具體實現:

  1. override def run(attemptId: Long): MapStatus = {
  2. ...
  3. val blockManager = SparkEnv.get.blockManager
  4. var shuffle: ShuffleBlocks = null
  5. var buckets: ShuffleWriterGroup = null
  6. try {
  7. // Obtain all the block writers for shuffle blocks.
  8. val ser = SparkEnv.get.serializerManager.get(dep.serializerClass)
  9. shuffle = blockManager.shuffleBlockManager.forShuffle(dep.shuffleId, numOutputSplits, ser)
  10. buckets = shuffle.acquireWriters(partition)
  11. // Write the map output to its associated buckets.
  12. for (elem <- rdd.iterator(split, taskContext)) {
  13. val pair = elem.asInstanceOf[Product2[Any, Any]]
  14. val bucketId = dep.partitioner.getPartition(pair._1)
  15. buckets.writers(bucketId).write(pair)
  16. }
  17. // Commit the writes. Get the size of each bucket block (total block size).
  18. var totalBytes = 0L
  19. val compressedSizes: Array[Byte] = buckets.writers.map { writer: BlockObjectWriter =>
  20. writer.commit()
  21. writer.close()
  22. val size = writer.size()
  23. totalBytes += size
  24. MapOutputTracker.compressSize(size)
  25. }
  26. ...
  27. } catch { case e: Exception =>
  28. // If there is an exception from running the task, revert the partial writes
  29. // and throw the exception upstream to Spark.
  30. if (buckets != null) {
  31. buckets.writers.foreach(_.revertPartialWrites())
  32. }
  33. throw e
  34. } finally {
  35. // Release the writers back to the shuffle block manager.
  36. if (shuffle != null && buckets != null) {
  37. shuffle.releaseWriters(buckets)
  38. }
  39. // Execute the callbacks on task completion.
  40. taskContext.executeOnCompleteCallbacks()
  41. }
  42. }
  43. }

在這個版本中為shuffle write新增了一個新的類ShuffleBlockManager,由ShuffleBlockManager來分配和管理bucket。同時ShuffleBlockManager為每一個bucket分配一個DiskObjectWriter,每個write handler擁有預設100KB的快取,使用這個write handler將Map output寫入檔案中。可以看到現在的寫入方式變為buckets.writers(bucketId).write(pair),也就是說Map output的key-value pair是逐個寫入到磁碟而不是預先把所有資料儲存在記憶體中在整體flush到磁碟中去。

ShuffleBlockManager的程式碼如下所示:

  1. private[spark]
  2. class ShuffleBlockManager(blockManager: BlockManager) {
  3. def forShuffle(shuffleId: Int, numBuckets: Int, serializer: Serializer): ShuffleBlocks = {
  4. new ShuffleBlocks {
  5. // Get a group of writers for a map task.
  6. override def acquireWriters(mapId: Int): ShuffleWriterGroup = {
  7. val bufferSize = System.getProperty("spark.shuffle.file.buffer.kb", "100").toInt * 1024
  8. val writers = Array.tabulate[BlockObjectWriter](numBuckets) { bucketId =>
  9. val blockId = ShuffleBlockManager.blockId(shuffleId, bucketId, mapId)
  10. blockManager.getDiskBlockWriter(blockId, serializer, bufferSize)
  11. }
  12. new ShuffleWriterGroup(mapId, writers)
  13. }
  14. override def releaseWriters(group: ShuffleWriterGroup) = {
  15. // Nothing really to release here.
  16. }
  17. }
  18. }
  19. }

Spark 0.8顯著減少了shuffle的記憶體壓力,現在Map output不需要先全部儲存在記憶體中,再flush到硬碟,而是record-by-record寫入到磁碟中。同時對於shuffle檔案的管理也獨立出新的ShuffleBlockManager進行管理,而不是與rdd cache檔案在一起了。

但是這一版Spark 0.8的shuffle write仍然有兩個大的問題沒有解決:

  • 首先依舊是shuffle檔案過多的問題,shuffle檔案過多一是會造成檔案系統的壓力過大,二是會降低IO的吞吐量。
  • 其次雖然Map output資料不再需要預先在記憶體中evaluate顯著減少了記憶體壓力,但是新引入的DiskObjectWriter所帶來的buffer開銷也是一個不容小視的記憶體開銷。假定我們有1k個Mapper和1k個Reducer,那麼就會有1M個bucket,於此同時就會有1M個write handler,而每一個write handler預設需要100KB記憶體,那麼總共需要100GB的記憶體。這樣的話僅僅是buffer就需要這麼多的記憶體,記憶體的開銷是驚人的。當然實際情況下這1k個Mapper是分時執行的話,所需的記憶體就只有cores * reducer numbers * 100KB大小了。但是reducer數量很多的話,這個buffer的記憶體開銷也是蠻厲害的。

為了解決shuffle檔案過多的情況,Spark 0.8.1引入了新的shuffle consolidation,以期顯著減少shuffle檔案的數量。

首先我們以圖例來介紹一下shuffle consolidation的原理。

spark shuffle  consolidation process

假定該job有4個Mapper和4個Reducer,有2個core,也就是能並行執行兩個task。我們可以算出Spark的shuffle write共需要16個bucket,也就有了16個write handler。在之前的Spark版本中,每一個bucket對應的是一個檔案,因此在這裡會產生16個shuffle檔案。

而在shuffle consolidation中每一個bucket並非對應一個檔案,而是對應檔案中的一個segment,同時shuffle consolidation所產生的shuffle檔案數量與Spark core的個數也有關係。在上面的圖例中,job的4個Mapper分為兩批執行,在第一批2個Mapper執行時會申請8個bucket,產生8個shuffle檔案;而在第二批Mapper執行時,申請的8個bucket並不會再產生8個新的檔案,而是追加寫到之前的8個檔案後面,這樣一共就只有8個shuffle檔案,而在檔案內部這有16個不同的segment。因此從理論上講shuffle consolidation所產生的shuffle檔案數量為C×R,其中C是Spark叢集的core number,R是Reducer的個數。

需要注意的是當 M=C時shuffle consolidation所產生的檔案數和之前的實現是一樣的。

Shuffle consolidation顯著減少了shuffle檔案的數量,解決了之前版本一個比較嚴重的問題,但是writer handler的buffer開銷過大依然沒有減少,若要減少writer handler的buffer開銷,我們只能減少Reducer的數量,但是這又會引入新的問題,下文將會有詳細介紹。

講完了shuffle write的進化史,接下來要講一下shuffle fetch了,同時還要講一下Spark的aggregator,這一塊對於Spark實際應用的效能至關重要。

Shuffle Fetch and Aggregator

Shuffle write寫出去的資料要被Reducer使用,就需要shuffle fetcher將所需的資料fetch過來,這裡的fetch包括本地和遠端,因為shuffle資料有可能一部分是儲存在本地的。Spark對shuffle fetcher實現了兩套不同的框架:NIO通過socket連線去fetch資料;OIO通過netty server去fetch資料。分別對應的類是BasicBlockFetcherIteratorNettyBlockFetcherIterator

在Spark 0.7和更早的版本中,只支援BasicBlockFetcherIterator,而BasicBlockFetcherIterator在shuffle資料量比較大的情況下performance始終不是很好,無法充分利用網路頻寬,為了解決這個問題,新增了新的shuffle fetcher來試圖取得更好的效能。對於早期shuffle效能的評測可以參看Spark usergroup。當然現在BasicBlockFetcherIterator的效能也已經好了很多,使用的時候可以對這兩種實現都進行測試比較。

接下來說一下aggregator。我們都知道在Hadoop MapReduce的shuffle過程中,shuffle fetch過來的資料會進行merge sort,使得相同key下的不同value按序歸併到一起供Reducer使用,這個過程可以參看下圖:

mapreduce shuffle process

所有的merge sort都是在磁碟上進行的,有效地控制了記憶體的使用,但是代價是更多的磁碟IO。

那麼Spark是否也有merge sort呢,還是以別的方式實現,下面我們就細細說明。

首先雖然Spark屬於MapReduce體系,但是對傳統的MapReduce演算法進行了一定的改變。Spark假定在大多數使用者的case中,shuffle資料的sort不是必須的,比如word count,強制地進行排序只會使效能變差,因此Spark並不在Reducer端做merge sort。既然沒有merge sort那Spark是如何進行reduce的呢?這就要說到aggregator了。

aggregator本質上是一個hashmap,它是以map output的key為key,以任意所要combine的型別為value的hashmap。當我們在做word count reduce計算count值的時候,它會將shuffle fetch到的每一個key-value pair更新或是插入到hashmap中(若在hashmap中沒有查詢到,則插入其中;若查詢到則更新value值)。這樣就不需要預先把所有的key-value進行merge sort,而是來一個處理一個,省下了外部排序這一步驟。但同時需要注意的是reducer的記憶體必須足以存放這個partition的所有key和count值,因此對記憶體有一定的要求。

在上面word count的例子中,因為value會不斷地更新,而不需要將其全部記錄在記憶體中,因此記憶體的使用還是比較少的。考慮一下如果是group by key這樣的操作,Reducer需要得到key對應的所有value。在Hadoop MapReduce中,由於有了merge sort,因此給予Reducer的資料已經是group by key了,而Spark沒有這一步,因此需要將key和對應的value全部存放在hashmap中,並將value合併成一個array。可以想象為了能夠存放所有資料,使用者必須確保每一個partition足夠小到記憶體能夠容納,這對於記憶體是一個非常嚴峻的考驗。因此Spark文件中建議使用者涉及到這類操作的時候儘量增加partition,也就是增加Mapper和Reducer的數量。

增加Mapper和Reducer的數量固然可以減小partition的大小,使得記憶體可以容納這個partition。但是我們在shuffle write中提到,bucket和對應於bucket的write handler是由Mapper和Reducer的數量決定的,task越多,bucket就會增加的更多,由此帶來write handler所需的buffer也會更多。在一方面我們為了減少記憶體的使用採取了增加task數量的策略,另一方面task數量增多又會帶來buffer開銷更大的問題,因此陷入了記憶體使用的兩難境地。

為了減少記憶體的使用,只能將aggregator的操作從記憶體移到磁碟上進行,Spark社群也意識到了Spark在處理資料規模遠遠大於記憶體大小時所帶來的問題。因此PR303提供了外部排序的實現方案,相信在Spark 0.9 release的時候,這個patch應該能merge進去,到時候記憶體的使用量可以顯著地減少。

End

本文詳細地介紹了Spark的shuffle實現是如何進化的,以及遇到問題解決問題的過程。shuffle作為Spark程式中很重要的一個環節,直接影響了Spark程式的效能,現如今的Spark版本雖然shuffle實現還存在著種種問題,但是相比於早期版本,已經有了很大的進步。開原始碼就是如此不停地迭代推進,隨著Spark的普及程度越來越高,貢獻的人越來越多,相信後續的版本會有更大的提升。


相關文章