SparkCore-Broadcast-7

fengye發表於2018-05-27

本系列文章源自JerryLead的SparkInternals,本文只是在作者的原文基礎上加入自己的理解,批註,和部分原始碼,作為學習之用
注:原文是基於Spark 1.0.2 , 而本篇筆記是基於spark 2.2.0, 對比後發現核心部分變化不大,依舊值得參考

Broadcast

顧名思義,broadcast 就是將資料從一個節點傳送到其他各個節點上去。這樣的場景很多,比如 driver 上有一張表,其他節點上執行的 task 需要 lookup 這張表,那麼 driver 可以先把這張表 copy 到這些節點,這樣 task 就可以在本地查表了。如何實現一個可靠高效的 broadcast 機制是一個有挑戰性的問題。先看看 Spark 官網上的一段話:

Broadcast variables allow the programmer to keep a read-only variable cached on each machine rather than shipping a copy of it with tasks. They can be used, for example, to give every node a copy of a large input dataset in an efficient manner. Spark also attempts to distribute broadcast variables using efficient broadcast algorithms to reduce communication cost.

問題:為什麼只能 broadcast 只讀的變數?

這就涉及一致性的問題,如果變數可以被更新,那麼一旦變數被某個節點更新,其他節點要不要一塊更新?如果多個節點同時在更新,更新順序是什麼?怎麼做同步?還會涉及 fault-tolerance 的問題。為了避免維護資料一致性問題,Spark 目前只支援 broadcast 只讀變數。

問題:broadcast 到節點而不是 broadcast 到每個 task?

因為每個 task 是一個執行緒,而且同在一個程式執行 tasks 都屬於同一個 application。因此每個節點(executor)上放一份就可以被所有 task 共享。

問題: 具體怎麼用 broadcast?

driver program 例子:

val data = List(1, 2, 3, 4, 5, 6)
val bdata = sc.broadcast(data)

val rdd = sc.parallelize(1 to 6, 2)
val observedSizes = rdd.map(_ => bdata.value.size)
複製程式碼

driver 使用 sc.broadcast() 宣告要 broadcast 的 data,bdata 的型別是 Broadcast。

rdd.transformation(func) 需要用 bdata 時,直接在 func 中呼叫,比如上面的例子中的 map() 就使用了 bdata.value.size。

問題:怎麼實現 broadcast?

broadcast 的實現機制很有意思:

1. 分發 task 的時候先分發 bdata 的元資訊

Driver 先建一個本地資料夾用以存放需要 broadcast 的 data,並啟動一個可以訪問該資料夾的 HttpServer。當呼叫val bdata = sc.broadcast(data)時就把 data 寫入資料夾,同時寫入 driver 自己的 blockManger 中(StorageLevel 為記憶體+磁碟),獲得一個 blockId,型別為 BroadcastBlockId。

//initialize
sparkSession.build()#env.broadcastManager.initialize()
    new TorrentBroadcastFactory.initialize()

//use broadcast
sc.broadcast()
    broadcastManager.newBroadcast()
        //Divide the object into multiple blocks and put those blocks in the block manager.
        new TorrentBroadcast[T](value_, nextBroadcastId.getAndIncrement()).writeBlocks()
            //儲存一份到driver上
            SparkEnv.get.blockManager.putSingle(broadcastId, value, MEMORY_AND_DISK, tellMaster = false)
                doPutIterator()#memoryStore.putIteratorAsValues()#diskStore.put(blockId)
            //以4m分別儲存block("spark.broadcast.blockSize", "4m"),並得到meta
            block MetaDatas = TorrentBroadcast.blockifyObject(value, blockSize..)
            foreach block MetaData : 
                blockManager.putBytes(BroadcastBlockId, MEMORY_AND_DISK_SER...)
                    doPutBytes()#memoryStore.putIteratorAsValues()#diskStore.putBytes()
                    //非同步複製資料,sc.broadcast()應該只會在driver端保留一份資料,replication=1,後面executorfetch資料時才慢慢增加broadcast的副本數量
                    if level.replication > 1 :ThreadUtils.awaitReady(replicate(ByteBufferBlockData(bytes, false)...)

//複製副本規則,作為參考
blockManager.replicate()
    //請求獲得其他BlockManager的id
    val initialPeers = getPeers(false)
        blockManagerMaster.getPeers(blockManagerId).sortBy(_.hashCode)
            //從driver上獲取其他節點
            driverEndpoint.askSync[Seq[BlockManagerId]](GetPeers(blockManagerId))
                //BlockManagerMasterEndpoint中返回非driver和非當前節點的blockManagerId
                blockManagerInfo.keySet.contains(blockManagerId)#blockManagerIds.filterNot { _.isDriver }.filterNot { _ == blockManagerId }.toSeq
             foreach block replicate replication-1 nodes: blockTransferService.uploadBlockSync()
                //後面就是傳送資訊給blockManager,再儲存資料通知driver
                blockManager.putBytes()#reportBlockStatus(blockId, putBlockStatus)
                    blockManagerMasterEndpoint.updateBlockInfo() //driver端更新資訊
複製程式碼

當呼叫rdd.transformation(func)時,如果 func 用到了 bdata,那麼 driver submitTask() 的時候會將 bdata 一同 func 進行序列化得到 serialized task注意序列化的時候不會序列化 bdata 中包含的 data

//TorrentBroadcast.scala 序列化的時候不會序列化 bdata 中包含的 data
// @transient表明不序列化_value
 @transient private lazy val _value: T = readBroadcastBlock()
  /** Used by the JVM when serializing this object. */
  private def writeObject(out: ObjectOutputStream): Unit = Utils.tryOrIOException {
    assertValid()
    out.defaultWriteObject()
  }
複製程式碼

上一章講到 serialized task 從 driverEndPoint 傳遞到 executor 時使用 RPC 的傳訊息機制,訊息不能太大,而實際的 data 可能很大,所以這時候還不能 broadcast data。

driver 為什麼會同時將 data 放到磁碟和 blockManager 裡面?放到磁碟是為了讓 HttpServer 訪問到,放到 blockManager 是為了讓 driver program 自身使用 bdata 時方便(其實我覺得不放到 blockManger 裡面也行)。

那麼什麼時候傳送真正的 data?在 executor 反序列化 task 的時候,會同時反序列化 task 中的 bdata 物件,這時候會呼叫 bdata 的 readObject() 方法。該方法先去本地 blockManager 那裡詢問 bdata 的 data 在不在 blockManager 裡面,如果不在就使用下面的兩種 fetch 方式之一去將 data fetch 過來。得到 data 後,將其存放到 blockManager 裡面,這樣後面執行的 task 如果需要 bdata 就不需要再去 fetch data 了。如果在,就直接拿來用了。

//runjob()
dagScheduler.submitMissingTasks(stage: Stage, jobId: Int)
     val taskIdToLocations = getPreferredLocs(stage.rdd, id)-----
        getCacheLocs()//從本地或者driver獲取快取rdd位置
        rdd.preferredLocations()//也會從checkpointrdd中尋找
    var taskBinary: Broadcast[Array[Byte]] = null
    try {
      // For ShuffleMapTask, serialize and broadcast (rdd, shuffleDep).
      // For ResultTask, serialize and broadcast (rdd, func).
      val taskBinaryBytes: Array[Byte] = stage match {
        case stage: ShuffleMapStage =>
          JavaUtils.bufferToArray(
            closureSerializer.serialize((stage.rdd, stage.shuffleDep): AnyRef))
        case stage: ResultStage => //把func也序列化了,func裡面包含broadcast變數
            //不會序列化 broadcast變數 中包含的 data
          JavaUtils.bufferToArray(closureSerializer.serialize((stage.rdd, stage.func): AnyRef))
      }
    taskBinary = sc.broadcast(taskBinaryBytes)//廣播task
    taskScheduler.submitTasks(new TaskSet(...))
    ...
複製程式碼
//TorrentBroadcast.scala
//使用lazy方式,真正反序列化使用_value才呼叫方法讀值
 @transient private lazy val _value: T = readBroadcastBlock()
 TorrentBroadcast.readBroadcastBlock()
     blockManager.getLocalValues()//本地讀取
        memoryStore.getValues(blockId)#diskStore.getBytes(blockId)
     readBlocks() //本地無則從driver/其他executor讀取
        foreach block : 
            blockManager.getRemoteBytes(BroadcastBlockId(id, "piece" + pid))
            blockManager.putBytes()//儲存在本地
    //整個broadcast儲存在本地
    blockManager.putSingle(broadcastId, obj, storageLevel, tellMaster = false)
    blocks.foreach(_.dispose()) //去重,把之前分開儲存的block刪除
複製程式碼

下面探討 broadcast data 時候的兩種實現方式:

2. HttpBroadcast

spark 2.2 的Broadcast package中已經去除了HttpBroadcast,只留下了TorrentBroadcast

顧名思義,HttpBroadcast 就是每個 executor 通過的 http 協議連線 driver 並從 driver 那裡 fetch data。

Driver 先準備好要 broadcast 的 data,呼叫sc.broadcast(data)後會呼叫工廠方法建立一個 HttpBroadcast 物件。該物件做的第一件事就是將 data 存到 driver 的 blockManager 裡面,StorageLevel 為記憶體+磁碟,blockId 型別為 BroadcastBlockId。

同時 driver 也會將 broadcast 的 data 寫到本地磁碟,例如寫入後得到 /var/folders/87/grpn1_fn4xq5wdqmxk31v0l00000gp/T/spark-6233b09c-3c72-4a4d-832b-6c0791d0eb9c/broadcast_0, 這個資料夾作為 HttpServer 的檔案目錄。

Driver 和 executor 啟動的時候,都會生成 broadcastManager 物件,呼叫 HttpBroadcast.initialize(),driver 會在本地建立一個臨時目錄用來存放 broadcast 的 data,並啟動可以訪問該目錄的 httpServer。

Fetch data:在 executor 反序列化 task 的時候,會同時反序列化 task 中的 bdata 物件,這時候會呼叫 bdata 的 readObject() 方法。該方法先去本地 blockManager 那裡詢問 bdata 的 data 在不在 blockManager 裡面,如果不在就使用 http 協議連線 driver 上的 httpServer,將 data fetch 過來。得到 data 後,將其存放到 blockManager 裡面,這樣後面執行的 task 如果需要 bdata 就不需要再去 fetch data 了。如果在,就直接拿來用了。

HttpBroadcast 最大的問題就是 driver 所在的節點可能會出現網路擁堵,因為 worker 上的 executor 都會去 driver 那裡 fetch 資料。

3. TorrentBroadcast

為了解決 HttpBroadast 中 driver 單點網路瓶頸的問題,Spark 又設計了一種 broadcast 的方法稱為 TorrentBroadcast,這個類似於大家常用的 BitTorrent 技術。基本思想就是將 data 分塊成 data blocks,然後假設有 executor fetch 到了一些 data blocks,那麼這個 executor 就可以被當作 data server 了,隨著 fetch 的 executor 越來越多,有更多的 data server 加入,data 就很快能傳播到全部的 executor 那裡去了。

HttpBroadcast 是通過傳統的 http 協議和 httpServer 去傳 data,在 TorrentBroadcast 裡面使用在上一章介紹的 blockManager.getRemoteValues() => NIO ShuffleClient 傳資料的方法來傳遞,讀取資料的過程與讀取 cached rdd 的方式類似,可以參閱 CacheAndCheckpoint 中的最後一張圖。

下面討論 TorrentBroadcast 的一些細節:

TorrentBroadcast

driver 端:

Driver 先把 data 序列化到 byteArray,然後切割成 BLOCK_SIZE(由 spark.broadcast.blockSize = 4MB 設定)大小的 data block,每個 data block 被 TorrentBlock 物件持有。切割完 byteArray 後,會將其回收,因此記憶體消耗雖然可以達到 2 * Size(data),但這是暫時的。

完成分塊切割後,就將分塊資訊(稱為 meta 資訊)存放到 driver 自己的 blockManager 裡面,StorageLevel 為記憶體+磁碟,同時會通知 driver 自己的 blockManagerMaster 說 meta 資訊已經存放好。通知 blockManagerMaster 這一步很重要,因為 blockManagerMaster 可以被 driver 和所有 executor 訪問到,資訊被存放到 blockManagerMaster 就變成了全域性資訊。

之後將每個分塊 data block 存放到 driver 的 blockManager 裡面,StorageLevel 為記憶體+磁碟。存放後仍然通知 blockManagerMaster 說 blocks 已經存放好。到這一步,driver 的任務已經完成。

Executor 端:

executor 收到 serialized task 後,先反序列化 task,這時候會反序列化 serialized task 中包含的 bdata 型別是 TorrentBroadcast,也就是去訪問 TorrentBroadcast._value,呼叫其readBroadcastBlock()方法。這個方法首先得到 bdata 物件,**然後發現 bdata 裡面沒有包含實際的 data。怎麼辦?**先詢問本地所在的 executor 裡的 blockManager 是會否包含 data(通過查詢 data 的 broadcastId),包含就直接從本地 blockManager 讀取 data。否則,就通過本地 blockManager 去連線 driver 的 blockManagerMaster 獲取 data 分塊的 meta 資訊,獲取資訊後,就開始了 BT 過程。

**BT 過程:**task 先在本地開一個陣列用於存放將要 fetch 過來的 data blocks val blocks = new Array[BlockData](numBlocks),然後打亂要 fetch 的 data blocks 的順序,for (pid <- Random.shuffle(Seq.range(0, numBlocks)))比如如果 data block 共有 5 個,那麼打亂後的 fetch 順序可能是 3-1-2-4-5。然後按照打亂後的順序去 fetch 一個個 data block。**每 fetch 到一個 block 就將其存放到 executor 的 blockManager 裡面,同時通知 driver 上的 blockManagerMaster 說該 data block 多了一個儲存地址。**這一步通知非常重要,意味著 blockManagerMaster 知道 data block 現在在 cluster 中有多份,下一個不同節點上的 task 再去 fetch 這個 data block 的時候,可以有兩個選擇了,而且會隨機選擇一個去 fetch。這個過程持續下去就是 BT 協議,隨著下載的客戶端越來越多,data block 伺服器也越來越多,就變成 p2p下載了。關於 BT 協議,Wikipedia 上有一個動畫

整個 fetch 過程結束後,task 會開一個大 Array[Byte],大小為 data 的總大小,然後將 data block 都 copy 到這個 Array,然後對 Array 中 bytes 進行反序列化得到原始的 data,這個過程就是 driver 序列化 data 的反過程。

最後將 data 存放到 task 所在 executor 的 blockManager 裡面,StorageLevel 為記憶體+磁碟。顯然,這時候 data 在 blockManager 裡存了兩份,不過等全部 executor 都 fetch 結束,儲存 data blocks 那份可以刪掉了。

問題:broadcast RDD 會怎樣?

@Andrew-Xia 回答道:不會怎樣,就是這個rdd在每個executor中例項化一份。

Discussion

公共資料的 broadcast 是很實用的功能,在 Hadoop 中使用 DistributedCache,比如常用的-libjars就是使用 DistributedCache 來將 task 依賴的 jars 分發到每個 task 的工作目錄。不過分發前 DistributedCache 要先將檔案上傳到 HDFS。這種方式的主要問題是資源浪費,如果某個節點上要執行來自同一 job 的 4 個 mapper,那麼公共資料會在該節點上存在 4 份(每個 task 的工作目錄會有一份)。但是通過 HDFS 進行 broadcast 的好處在於單點瓶頸不明顯,因為公共 data 首先被分成多個 block,然後不同的 block 存放在不同的節點。這樣,只要所有的 task 不是同時去同一個節點 fetch 同一個 block,網路擁塞不會很嚴重。

對於 Spark 來講,broadcast 時考慮的不僅是如何將公共 data 分發下去的問題,還要考慮如何讓同一節點上的 task 共享 data。

對於第一個問題,Spark 設計了兩種 broadcast 的方式,傳統存在單點瓶頸問題的 HttpBroadcast,和類似 BT 方式的 TorrentBroadcast。HttpBroadcast 使用傳統的 client-server 形式的 HttpServer 來傳遞真正的 data,而 TorrentBroadcast 使用 blockManager 自帶的 NIO 通訊方式來傳遞 data。TorrentBroadcast 存在的問題是慢啟動佔記憶體,慢啟動指的是剛開始 data 只在 driver 上有,要等 executors fetch 很多輪 data block 後,data server 才會變得可觀,後面的 fetch 速度才會變快。executor 所佔記憶體的在 fetch 完 data blocks 後進行反序列化時需要將近兩倍 data size 的記憶體消耗。不管哪一種方式,driver 在分塊時會有兩倍 data size 的記憶體消耗。

對於第二個問題,每個 executor 都包含一個 blockManager 用來管理存放在 executor 裡的資料,將公共資料存放在 blockManager 中(StorageLevel 為記憶體+磁碟),可以保證在 executor 執行的 tasks 能夠共享 data。

其實 Spark 之前還嘗試了一種稱為 TreeBroadcast 的機制,詳情可以見技術報告 Performance and Scalability of Broadcast in Spark

更深入點,broadcast 可以用多播協議來做,不過多播使用 UDP,不是可靠的,仍然需要應用層的設計一些可靠性保障機制。