Spark的shuffle

@Author  : Spinach | GHB
@Link    : http://blog.csdn.net/bocai8058

0 hadoop的shuffle與spark的shuffle的簡單比較

從high-level 的角度來看,兩者並沒有大的差別。

  • 都是將 mapper(Spark 裏是 ShuffleMapTask)的輸出進行 partition,不同的 partition 送到不同的 reducer(Spark 裏 reducer 可能是下一個 stage 裏的 ShuffleMapTask,也可能是 ResultTask)。
  • Reducer 以內存作緩衝區,邊 shuffle 邊 aggregate 數據,等到數據 aggregate 好以後進行 reduce() (Spark 裏可能是後續的一系列操作)。

從low-level 的角度來看,兩者差別不小。

  • Hadoop MapReduce 是 sort-based,進入 combine() 和 reduce() 的 records 必須先 sort。這樣的好處在於 combine/reduce() 可以處理大規模的數據,因爲其輸入數據可以通過外排得到(mapper 對每段數據先做排序,reducer 的 shuffle 對排好序的每段數據做歸併)。
  • 目前的 Spark 默認選擇的是 hash-based,通常使用 HashMap 來對 shuffle 來的數據進行 aggregate,不會對數據進行提前排序。如果用戶需要經過排序的數據,那麼需要自己調用類似 sortByKey() 的操作;如果你是Spark 1.1的用戶,可以將spark.shuffle.manager設置爲sort,則會對數據進行排序。在Spark 1.2中,sort將作爲默認的Shuffle實現。

從實現角度來看,兩者也有不少差別。

  • Hadoop MapReduce 將處理流程劃分出明顯的幾個階段:map(), spill, merge, shuffle, sort, reduce() 等。每個階段各司其職,可以按照過程式的編程思想來逐一實現每個階段的功能。
  • 在 Spark 中,沒有這樣功能明確的階段,只有不同的 stage 和一系列的 transformation(),所以 spill, merge, aggregate 等操作需要蘊含在 transformation() 中。

1 spark的shuffle

將 map 端劃分數據、持久化數據的過程稱爲 shuffle write。

將 reducer 讀入數據、aggregate 數據的過程稱爲 shuffle read。

那麼在 Spark 中,問題就變爲怎麼在 job 的邏輯或者物理執行圖中加入 shuffle write 和 shuffle read 的處理邏輯?以及兩個處理邏輯應該怎麼高效實現?

1.1 shuffle write

由於不要求數據有序,shuffle write 的任務很簡單:將數據 partition 好,並持久化。持久化的好處:

  • 要減少內存存儲空間壓力;
  • 爲了fault-tolerance。
1.1.1 第一種方法

shuffle write 的任務很簡單,那麼實現也很簡單:將 shuffle write 的處理邏輯加入到 ShuffleMapStage(ShuffleMapTask 所在的 stage) 的最後,該 stage 的 final RDD 每輸出一個 record 就將其 partition 並持久化。

上圖有 4 個 ShuffleMapTask 要在同一個 worker node 上運行,CPU core 數爲 2,可以同時運行兩個 task。每個 task 的執行結果(該 stage 的 finalRDD 中某個 partition 包含的 records)被逐一寫到本地磁盤上。每個 task 包含 R 個緩衝區,R = reducer 個數(也就是下一個 stage 中 task 的個數),緩衝區被稱爲 bucket,其大小爲spark.shuffle.file.buffer.kb ,默認是 32KB(Spark 1.1 版本以前是 100KB)。

其實 bucket 是一個廣義的概念,代表 ShuffleMapTask 輸出結果經過 partition 後要存放的地方,
這裏爲了細化數據存放位置和數據名稱,僅僅用 bucket 表示緩衝區。

ShuffleMapTask 的執行過程很簡單:先利用 pipeline 計算得到 finalRDD 中對應 partition 的 records。每得到一個 record 就將其送到對應的 bucket 裏,具體是哪個 bucket 由partitioner.partition(record.getKey()))決定。每個 bucket 裏面的數據會不斷被寫到本地磁盤上,形成一個 ShuffleBlockFile,或者簡稱 FileSegment。之後的 reducer 會去 fetch 屬於自己的 FileSegment,進入 shuffle read 階段。

這樣的實現很簡單,但有幾個問題:

1. 產生的FileSegment過多。
2. 每個ShuffleMapTask產生R(reducer 個數)個FileSegment/bucket,M個ShuffleMapTask(一般worker同時可以運行cores個ShuffleMapTask)就會產生M * R個文件;
3. 佔用的內存空間也就達到了M * R * 32 KB。
4. 一般Spark job的M和R都很大,因此磁盤上會存在大量的數據文件。緩衝區佔用內存空間大。

如何解決呢?請看第二種方法:FileConsolidation方法。

1.1.2 第二種方法:FileConsolidation方法

可以明顯看出,在一個core上連續執行的ShuffleMapTasks可以共用一個輸出文件ShuffleFile。

  • 先執行完的ShuffleMapTask形成ShuffleBlock i;
  • 後執行的ShuffleMapTask可以將輸出數據直接追加到ShuffleBlock i後面,形成ShuffleBlock i’,每個ShuffleBlock被稱爲FileSegment;
  • 下一個stage的reducer只需要fetch整個ShuffleFile就行了。這樣,每個worker持有的文件數降爲cores * R。FileConsolidation功能可以通過spark.shuffle.consolidateFiles=true來開啓。

1.2 shuffle reade

先看一張包含 ShuffleDependency 的物理執行圖,來自 reduceByKey:

很自然地,要計算 ShuffleRDD 中的數據,必須先把 MapPartitionsRDD 中的數據 fetch 過來。那麼問題就來了:

  1. 在什麼時候 fetch,parent stage 中的一個 ShuffleMapTask 執行完還是等全部 ShuffleMapTasks 執行完?
  2. 邊 fetch 邊處理還是一次性 fetch 完再處理?
  3. fetch 來的數據存放到哪裏?
  4. 怎麼獲得要 fetch 的數據的存放位置?

解決問題:

  1. 在什麼時候 fetch ?當 parent stage 的所有 ShuffleMapTasks 結束後再 fetch。理論上講,一個 ShuffleMapTask 結束後就可以 fetch,但是爲了迎合 stage 的概念(即一個 stage 如果其 parent stages 沒有執行完,自己是不能被提交執行的),還是選擇全部 ShuffleMapTasks 執行完再去 fetch。因爲 fetch 來的 FileSegments 要先在內存做緩衝,所以一次 fetch 的 FileSegments 總大小不能太大。Spark 規定這個緩衝界限不能超過 spark.reducer.maxMbInFlight,這裏用 softBuffer 表示,默認大小爲 48MB。一個 softBuffer 裏面一般包含多個 FileSegment,但如果某個 FileSegment 特別大的話,這一個就可以填滿甚至超過 softBuffer 的界限。
  2. 邊 fetch 邊處理還是一次性 fetch 完再處理?邊 fetch 邊處理。本質上,MapReduce shuffle 階段就是邊 fetch 邊使用 combine() 進行處理,只是 combine() 處理的是部分數據。MapReduce 爲了讓進入 reduce() 的 records 有序,必須等到全部數據都 shuffle-sort 後再開始 reduce()。因爲 Spark 不要求 shuffle 後的數據全局有序,因此沒必要等到全部數據 shuffle 完成後再處理。那麼如何實現邊 shuffle 邊處理,而且流入的 records 是無序的?答案是使用可以 aggregate 的數據結構,比如 HashMap。每 shuffle 得到(從緩衝的 FileSegment 中 deserialize 出來)一個 <Key, Value> record,直接將其放進 HashMap 裏面。如果該 HashMap 已經存在相應的 Key,那麼直接進行 aggregate 也就是 func(hashMap.get(Key), Value),比如上面 WordCount 例子中的 func 就是 hashMap.get(Key) + Value,並將 func 的結果重新 put(key) 到 HashMap 中去。這個 func 功能上相當於 reduce(),但實際處理數據的方式與 MapReduce reduce() 有差別,差別相當於下面兩段程序的差別。
// MapReduce
reduce(K key, Iterable<V> values) { 
    result = process(key, values)
    return result   
}

// Spark
reduce(K key, Iterable<V> values) {
    result = null 
    for (V value : values) 
        result  = func(result, value)
    return result
}

MapReduce 可以在 process 函數裏面可以定義任何數據結構,也可以將部分或全部的 values 都 cache 後再進行處理,非常靈活。而 Spark 中的 func 的輸入參數是固定的,一個是上一個 record 的處理結果,另一個是當前讀入的 record,它們經過 func 處理後的結果被下一個 record 處理時使用。因此一些算法比如求平均數,在 process 裏面很好實現,直接sum(values)/values.length,而在 Spark 中 func 可以實現sum(values),但不好實現/values.length。更多的 func 將會在下面的章節細緻分析。

  1. fetch 來的數據存放到哪裏?剛 fetch 來的 FileSegment 存放在 softBuffer 緩衝區,經過處理後的數據放在內存+磁盤上。這裏我們主要討論處理後的數據,可以靈活設置這些數據是“只用內存”還是“內存+磁盤”。如果spark.shuffle.spill = false就只用內存。內存使用的是AppendOnlyMap ,類似 Java 的HashMap,內存+磁盤使用的是ExternalAppendOnlyMap,如果內存空間不足時,ExternalAppendOnlyMap可以將 <K, V> records 進行 sort 後 spill 到磁盤上,等到需要它們的時候再進行歸併,後面會詳解。使用“內存+磁盤”的一個主要問題就是如何在兩者之間取得平衡?在 Hadoop MapReduce 中,默認將 reducer 的 70% 的內存空間用於存放 shuffle 來的數據,等到這個空間利用率達到 66% 的時候就開始 merge-combine()-spill。在 Spark 中,也適用同樣的策略,一旦 ExternalAppendOnlyMap 達到一個閾值就開始 spill,具體細節下面會討論。

  2. 怎麼獲得要 fetch 的數據的存放位置?在上一章討論物理執行圖中的 stage 劃分的時候,我們強調 “一個 ShuffleMapStage 形成後,會將該 stage 最後一個 final RDD 註冊到 MapOutputTrackerMaster.registerShuffle(shuffleId, rdd.partitions.size),這一步很重要,因爲 shuffle 過程需要 MapOutputTrackerMaster 來指示 ShuffleMapTask 輸出數據的位置”。因此,reducer 在 shuffle 的時候是要去 driver 裏面的 MapOutputTrackerMaster 詢問 ShuffleMapTask 輸出的數據位置的。每個 ShuffleMapTask 完成時會將 FileSegment 的存儲位置信息彙報給 MapOutputTrackerMaster。

1.2.1 reduceByKey(func)

上面初步介紹了 reduceByKey() 是如何實現邊 fetch 邊 reduce() 的。需要注意的是雖然 Example(WordCount) 中給出了各個 RDD 的內容,但一個 partition 裏面的 records 並不是同時存在的。比如在 ShuffledRDD 中,每 fetch 來一個 record 就立即進入了 func 進行處理。MapPartitionsRDD 中的數據是 func 在全部 records 上的處理結果。從 record 粒度上來看,reduce() 可以表示如下:

可以看到,fetch 來的 records 被逐個 aggreagte 到 HashMap 中,等到所有 records 都進入 HashMap,就得到最後的處理結果。唯一要求是 func 必須是 commulative 的(參見上面的 Spark 的 reduce() 的代碼)。

ShuffledRDD 到 MapPartitionsRDD 使用的是 mapPartitionsWithContext 操作。

爲了減少數據傳輸量,MapReduce 可以在 map 端先進行 combine(),其實在 Spark 也可以實現,只需要將上圖 ShuffledRDD => MapPartitionsRDD 的 mapPartitionsWithContext 在 ShuffleMapStage 中也進行一次即可,比如 reduceByKey 例子中 ParallelCollectionRDD => MapPartitionsRDD 完成的就是 map 端的 combine()。

1.2.1.1 對比MapReduce的map()-reduce()和Spark中的reduceByKey()
  • map 端的區別:map() 沒有區別。對於 combine(),MapReduce 先 sort 再 combine(),Spark 直接在 HashMap 上進行 combine()。
  • reduce 端區別:MapReduce 的 shuffle 階段先 fetch 數據,數據量到達一定規模後 combine(),再將剩餘數據 merge-sort 後 reduce(),reduce() 非常靈活。Spark 邊 fetch 邊 reduce()(在 HashMap 上執行 func),因此要求 func 符合 commulative 的特性。

從內存利用上來對比:

  • map 端區別:MapReduce 需要開一個大型環形緩衝區來暫存和排序 map() 的部分輸出結果,但 combine() 不需要額外空間(除非用戶自己定義)。 Spark 需要 HashMap 內存數據結構來進行 combine(),同時輸出 records 到磁盤上時也需要一個小的 buffer(bucket)。
  • reduce 端區別:MapReduce 需要一部分內存空間來存儲 shuffle 過來的數據,combine() 和 reduce() 不需要額外空間,因爲它們的輸入數據分段有序,只需歸併一下就可以得到。在 Spark 中,fetch 時需要 softBuffer,處理數據時如果只使用內存,那麼需要 HashMap 來持有處理後的結果。如果使用內存+磁盤,那麼在 HashMap 存放一部分處理後的數據。
1.2.2 groupByKey(numPartitions)
與 reduceByKey() 流程一樣,只是 func 變成 result = result ++ record.value,功能是將每個 key 對應的所有 values 鏈接在一起。result 來自 hashMap.get(record.key),計算後的 result 會再次被 put 到 hashMap 中。與 reduceByKey() 的區別就是 groupByKey() 沒有 map 端的 combine()。對於 groupByKey() 來說 map 端的 combine() 只是減少了重複 Key 佔用的空間,如果 key 重複率不高,沒必要 combine(),否則,最好能夠 combine()。
1.2.3 distinct(numPartitions)
與 reduceByKey() 流程一樣,只是 func 變成 result = result == null? record.value : result,如果 HashMap 中沒有該 record 就將其放入,否則捨棄。與 reduceByKey() 相同,在map 端存在 combine()。
1.2.4 sortByKey(ascending, numPartitions)
sortByKey() 中 ShuffledRDD => MapPartitionsRDD 的處理邏輯與 reduceByKey() 不太一樣,沒有使用 HashMap 和 func 來處理 fetch 過來的 records。

sortByKey() 中 ShuffledRDD => MapPartitionsRDD 的處理邏輯是:將 shuffle 過來的一個個 record 存放到一個 Array 裏,然後按照 Key 來對 Array 中的 records 進行 sort。

1.3 shuffle read中的HashMap

HashMap 是 Spark shuffle read 過程中頻繁使用的、用於 aggregate 的數據結構。

graph LR
A[HashMap實現]-->B[全內存的AppendOnlyMap]
A-->C[內存+磁盤的ExternalAppendOnlyMap]
1.3.1 全內存AppendOnlyMap

AppendOnlyMap 的官方介紹是 A simple open hash table optimized for the append-only use case, where keys are never removed, but the value for each key may be changed。意思是類似 HashMap,但沒有remove(key)方法。其實現原理很簡單,開一個大 Object 數組,藍色部分存儲 Key,白色部分存儲 Value。如下圖:

當要 put(K, V) 時,先 hash(K) 找存放位置,如果存放位置已經被佔用,就使用 Quadratic probing 探測方法來找下一個空閒位置。對於圖中的 K6 來說,第三次查找找到 K4 後面的空閒位置,放進去即可。get(K6) 的時候類似,找三次找到 K6,取出緊挨着的 V6,與先來的 value 做 func,結果重新放到 V6 的位置。

迭代 AppendOnlyMap 中的元素的時候,從前到後掃描輸出。

如果 Array 的利用率達到 70%,那麼就擴張一倍,並對所有 key 進行 rehash 後,重新排列每個 key 的位置。

AppendOnlyMap 還有一個 destructiveSortedIterator(): Iterator[(K, V)] 方法,可以返回 Array 中排序後的 (K, V) pairs。實現方法很簡單:先將所有 (K, V) pairs compact 到 Array 的前端,並使得每個 (K, V) 佔一個位置(原來佔兩個),之後直接調用 Array.sort() 排序,不過這樣做會破壞數組(key 的位置變化了)。

1.3.2 內存+磁盤ExternalAppendOnlyMap

相比 AppendOnlyMap,ExternalAppendOnlyMap 的實現略複雜,但邏輯其實很簡單,類似 Hadoop MapReduce 中的 shuffle-merge-combine-sort 過程:

ExternalAppendOnlyMap 持有一個 AppendOnlyMap,shuffle 來的一個個 (K, V) record 先 insert 到 AppendOnlyMap 中,insert 過程與原始的 AppendOnlyMap 一模一樣。如果 AppendOnlyMap 快被裝滿時檢查一下內存剩餘空間是否可以夠擴展,夠就直接在內存中擴展,不夠就 sort 一下 AppendOnlyMap,將其內部所有 records 都 spill 到磁盤上。圖中 spill 了 4 次,每次 spill 完在磁盤上生成一個 spilledMap 文件,然後重新 new 出來一個 AppendOnlyMap。最後一個 (K, V) record insert 到 AppendOnlyMap 後,表示所有 shuffle 來的 records 都被放到了 ExternalAppendOnlyMap 中,但不表示 records 已經被處理完,因爲每次 insert 的時候,新來的 record 只與 AppendOnlyMap 中的 records 進行 aggregate,並不是與所有的 records 進行 aggregate(一些 records 已經被 spill 到磁盤上了)。因此當需要 aggregate 的最終結果時,需要對 AppendOnlyMap 和所有的 spilledMaps 進行全局 merge-aggregate。

全局 merge-aggregate 的流程也很簡單:先將 AppendOnlyMap 中的 records 進行 sort,形成 sortedMap。然後利用 DestructiveSortedIterator 和 DiskMapIterator 分別從 sortedMap 和各個 spilledMap 讀出一部分數據(StreamBuffer)放到 mergeHeap 裏面。StreamBuffer 裏面包含的 records 需要具有相同的 hash(key),所以圖中第一個 spilledMap 只讀出前三個 records 進入 StreamBuffer。mergeHeap 顧名思義就是使用堆排序不斷提取出 hash(firstRecord.Key) 相同的 StreamBuffer,並將其一個個放入 mergeBuffers 中,放入的時候與已經存在於 mergeBuffers 中的 StreamBuffer 進行 merge-combine,第一個被放入 mergeBuffers 的 StreamBuffer 被稱爲 minBuffer,那麼 minKey 就是 minBuffer 中第一個 record 的 key。當 merge-combine 的時候,與 minKey 相同的 records 被 aggregate 一起,然後輸出。整個 merge-combine 在 mergeBuffers 中結束後,StreamBuffer 剩餘的 records 隨着 StreamBuffer 重新進入 mergeHeap。一旦某個 StreamBuffer 在 merge-combine 後變爲空(裏面的 records 都被輸出了),那麼會使用 DestructiveSortedIterator 或 DiskMapIterator 重新裝填 hash(key) 相同的 records,然後再重新進入 mergeHeap。

整個insert-merge-aggregate 的過程有三點需要進一步探討一下

內存剩餘空間檢測

與 Hadoop MapReduce 規定 reducer 中 70% 的空間可用於 shuffle-sort 類似,Spark 也規定 executor 中 spark.shuffle.memoryFraction * spark.shuffle.safetyFraction 的空間(默認是0.3 * 0.8)可用於 ExternalOnlyAppendMap。Spark 略保守是不是?更保守的是這 24% 的空間不是完全用於一個 ExternalOnlyAppendMap 的,而是由在 executor 上同時運行的所有 reducer 共享的。爲此,exectuor 專門持有一個 ShuffleMemroyMap: HashMap[threadId, occupiedMemory] 來監控每個 reducer 中 ExternalOnlyAppendMap 佔用的內存量。每當 AppendOnlyMap 要擴展時,都會計算 ShuffleMemroyMap 持有的所有 reducer 中的 AppendOnlyMap 已佔用的內存 + 擴展後的內存 是會否會大於內存限制,大於就會將 AppendOnlyMap spill 到磁盤。有一點需要注意的是前 1000 個 records 進入 AppendOnlyMap 的時候不會啓動是否要 spill 的檢查,需要擴展時就直接在內存中擴展。

AppendOnlyMap 大小估計

爲了獲知 AppendOnlyMap 佔用的內存空間,可以在每次擴展時都將 AppendOnlyMap reference 的所有 objects 大小都算一遍,然後加和,但這樣做非常耗時。所以 Spark 設計了粗略的估算算法,算法時間複雜度是 O(1),核心思想是利用 AppendOnlyMap 中每次 insert-aggregate record 後 result 的大小變化及一共 insert 的 records 的個數來估算大小,具體見 SizeTrackingAppendOnlyMap 和 SizeEstimator。

Spill 過程

與 shuffle write 一樣,在 spill records 到磁盤上的時候,會建立一個 buffer 緩衝區,大小仍爲 spark.shuffle.file.buffer.kb ,默認是 32KB。另外,由於 serializer 也會分配緩衝區用於序列化和反序列化,所以如果一次 serialize 的 records 過多的話緩衝區會變得很大。Spark 限制每次 serialize 的 records 個數爲 spark.shuffle.spill.batchSize,默認是 10000。

引用:http://www.360doc.com/content/17/0607/12/14808334_660748147.shtml | https://blog.csdn.net/u010697988/article/details/70173104 | https://blog.csdn.net/yuanxiaojun1990/article/details/50360261


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章