Mars如何分散式地執行

繼盛發表於2019-01-08

先前,我們已經介紹過 Mars 是什麼。如今 Mars 已在 Github 開源並對內上線試用,本文將介紹 Mars 已實現的分散式執行架構,歡迎大家提出意見。

架構

Mars 提供了一套分散式執行 Tensor 的庫。該庫使用 mars.actors 實現的 Actor 模型編寫,包含 Scheduler、Worker 和 Web 服務。

使用者向 Mars Web Service 提交的是由 Tensor 組成的 Graph。Web Service 接收這些圖並提交到一臺 Scheduler。在提交作業到各個 Worker 之前,Mars Scheduler 先將 Tensor 圖編譯成一張由 Chunk 和 Operand 組成的圖,此後對圖進行分析和切分。此後,Scheduler 在所有 Scheduler 中根據一致性雜湊建立一系列控制單個 Operand 執行的 OperandActor。Operand 以符合拓撲序的順序進行排程,當所有 Operand 完成執行,整張圖將被標記為已完成,客戶端能夠從 Web 中拉取結果。整個執行過程如下圖所述。

image.png

作業提交

使用者端通過 RESTful API 向 Mars 服務提交作業。使用者通過編寫 Tensor 上的程式碼,此後通過 session.run(tensor) 將 Tensor 操作轉換為 Tensor 構成的 Graph 並提交到 Web API。此後,Web API 將作業提交到 SessionActor 並在叢集中建立一個 GraphActor 用於圖的分析和管理。使用者端則開始查詢圖的執行狀態,直至執行結束。

在 GraphActor 中,我們首先根據 chunks 設定將 Tensor 圖轉換為 Operand 和 Chunk 組成的圖,這一過程使得圖可以被進一步拆分並能夠並行執行。此後,我們在圖上進行一系列的分析以獲得 Operand 的優先順序,同時向起始 Operand 指派 Worker,關於這一部分的細節可以參考 準備執行圖 章節。此後,每個 Operand 均建立一個 OperandActor 用於控制該 Operand 的具體執行。當 Operand 處於 READY狀態(如同在 Operand 狀態 章節描述的那樣),Scheduler 將會為 Operand 選擇目標 Worker,隨後作業被提交 Worker 進行實際的執行。

執行控制

當一個 Operand 被提交到 Worker,OperandActor 等待 Worker 上的回撥。如果 Operand 執行成功,Operand 的後繼將被排程。如果 Operand 執行失敗,OperandActor 將會嘗試數次,如果仍失敗則將此次執行標記為失敗。

取消作業

使用者端可以使用 RESTful API 取消執行中的作業。取消請求將被寫入 Graph 的狀態儲存中,同時 GraphActor 上的取消介面將被呼叫。如果作業在準備階段,它將在檢測到停止請求後立即結束,否則請求將被下發到每個 OperandActor,並設定狀態為 CANCELLING。如果此時 Operand 沒有執行,Operand 狀態將被直接置為 CANCELLED。如果 Operand 正在執行,停止請求將被下發到 Worker 中並導致一個 ExecutionInterrupted 錯誤,該錯誤將返回給 OperandActor,此時 Operand 的狀態將被標記為 CANCELLED。

準備執行圖

當一個 Tensor 圖被提交到 Mars Scheduler,一張包含更細粒度的,由 Operand 和 Chunk 構成的圖將根據資料來源中包含的 chunks 引數被生成。

圖壓縮

當完成 Chunk 圖的生成後,我們將會通過合併圖中相鄰的節點來減小圖的規模,這一合併也能讓我們充分利用 numexpr 這樣的加速庫來加速計算過程。目前 Mars 僅會合並形成單條鏈的 Operand。例如,當執行下面的程式碼

import mars.tensor as mt
a = mt.random.rand(100, chunks=100)
b = mt.random.rand(100, chunks=100)
c = (a + b).sum()

Mars 將會合並 Operand ADD 和 SUM 成為 FUSE 節點。RAND Operand 不會被合併,因為它們並沒有和 ADD 及 SUM 組成一條簡單的直線。

image.png

初始 Worker 分配

為 Operand 分配 Worker 對於圖執行的效能而言至關重要。隨機分配初始 Operand 可能導致巨大的網路開銷,並有可能導致不同 Worker 間作業分配的不平衡。因為非初始節點的分配能夠根據其前驅生成資料的物理分佈及各個 Worker 的空閒情況方便地確定,在執行圖準備階段,我們只考慮初始 Operand 的分配問題。

初始 Worker 分配需要遵循幾個準則。首先,分配給每個 Worker 執行的 Operand 需要儘量保持平衡滿,這能夠使計算叢集在整個執行階段都有較高的利用率,這在執行的最後階段顯得尤其重要。其次,初始節點分配需要使後續節點執行時的網路”傳輸儘量小。也就是說,初始點分配需要充分遵循區域性性原則。

需要注意的是,上述準則在某些情況下會彼此衝突。一個網路傳輸量最小的分配方案可能會非常偏斜。我們開發了一套啟發式演算法來獲取兩個目標的平衡,該演算法描述如下:

  1. 選擇列表中的第一個初始節點和第一臺機器;
  2. 從 Operand 圖轉換出的無向圖中自該點開始進行深度優先搜尋;
  3. 如果另一個未被分配的初始節點被訪問到,我們將其分配給步驟1中選擇的機器;
  4. 當訪問到的 Operand 總數大於平均每個 Worker 接受的 Operand 個數時,停止分配;
  5. 前往步驟1,如果仍有 Worker 未被分配 Operand,否則結束。

排程策略

當一個 Operand 組成的 Graph 執行時,合適的執行順序會減少叢集中暫存的資料總量,從而減小資料被 Spill 到磁碟的可能性。合適的 Worker 能夠減少執行時網路傳輸的總量。

Operand 選擇策略

合適的執行順序能夠顯著減小叢集中暫存的資料總量。下圖中展示了 Tree Reduction 的例子,圓形代表 Operand,方形代表 Chunk,紅色代表 Operand 正在執行,藍色代表 Operand 可被執行,綠色代表 Operand 產生的 Chunk 已被儲存,灰色代表 Operand 及其相關資料已被釋放。假設我們有兩臺 Worker,並且每個 Operand 的資源使用量均相等,每張圖展示的是不同策略下經過5個時間單元的執行後的狀態。左圖展示的是節點依照層次分別執行,而右圖展示的是依照接近深度優先的順序執行。左圖中,有6個 Chunk 的資料需要暫存,右圖只有2個。

image.png

因為我們的目標是減少儲存在叢集中的資料總數,我們為進入 READY 狀態的 Operand 設定了一套優先順序策略:

  1. 深度更大的 Operand 需要被優先執行;
  2. 被更深的 Operand 依賴的 Operand 需要被優先執行;
  3. 輸出規模更小的節點需要被優先執行。

Worker 選擇策略

當 Scheduler 準備執行圖時,初始 Operand 的 Worker 已被確定。我們選擇後續 Operand 分配 Worker 的依據是輸入資料所在的 Worker。如果某個 Worker 擁有的輸入資料大小最大,則該 Worker 將被選擇用於執行後續 Operand。如果這樣的 Worker 有多個,則各個候選 Worker 的資源狀況將起到決定作用。

Operand 狀態

Mars 中的每一個操作符都被一個 OperandActor 單獨排程。執行的過程是一個狀態轉移的過程。在 OperandActor 中,我們為每一個狀態的進入過程定義一個狀態轉移函式。起始 Operand 在初始化時位於 READY 狀態,非起始 Operand 在初始化時則位於 UNSCHEDULED 狀態。當給定的條件滿足,Operand 將轉移到另一個狀態並執行相應的操作。狀態轉移的流程可以參考下圖:

image.png

我們在下面描述每個狀態的含義及 Mats 在這些狀態下執行的操作。

  • UNSCHEDUED:一個 Operand 位於此狀態,當它的上游資料沒有準備好。
  • READY:一個 Operand 位於此狀態,當所有上游輸入資料均已準備完畢。在進入這一狀態時,OperandActor 向 AssignerActor 中選擇的所有 Worker 提交作業。如果某一 Worker 準備執行作業,它將向 Scheduler 傳送訊息,Scheduler 將向其他 Worker 傳送停止執行的訊息,此後向該 Worker 傳送訊息以啟動作業執行。
  • RUNNING:一個 Operand 位於此狀態,當它的執行已經啟動。在進入此狀態時,OperandActor 會檢查作業是否已經提交。如果尚未提交,OperandActor 將構造一個由 FetchChunk Operand 和當前 Operand 組成的圖,並將其提交到 Worker 中。此後,OperandActor 會在 Worker 中註冊一個回撥來獲取作業執行完成的訊息。
  • FINISHED:一個 Operand 位於此狀態,當作業執行已完成。當 Operand 進入此狀態,且 Operand 無後繼,一個訊息將被髮送到 GraphActor 以決定是否整個 Graph 的執行都已結束。與此同時,OperandActor 向它的前驅和後繼傳送執行完成的訊息。如果一個前驅收到此訊息,它將檢查是否所有的後繼都已執行完成。如是,當前 Operand 上的資料可以被釋放。如果一個後繼收到此訊息,它將檢查是否所有的前驅已完成。如是,該後繼的狀態可以轉移到 READY。
  • FREED:一個 Operand 位於此狀態,當其上所有資料都已被釋放。
  • CANCELLED:一個 Operand 位於此狀態,當所有重新執行的嘗試均告失敗。當 Operand 進入此狀態,它將把相同狀態傳遞到後繼節點。
  • CANCELLING:一個 Operand 位於此狀態,當它正在被取消執行。如果此前作業正在執行,一個取消執行的請求會被髮送到 Worker 上。
  • CANCELLED:一個 Operand 位於此狀態,當執行已被取消並停止執行。如果執行進入這一狀態,OperandActor 會嘗試將書友的後繼都轉為 CANCELLING。

Worker 中的執行細節

一個 Mars Worker 包含多個程式,以減少全域性直譯器鎖(GIL)對執行的影響。具體的執行在獨立的程式中完成。為減少不必要的記憶體拷貝和程式間通訊,Mars Worker 使用共享記憶體來儲存執行結果。

當一個作業被提交到 Worker,它將首先被置於佇列中等待分配記憶體。當記憶體被分配後,其他 Worker 上的資料,或者當前 Worker 上已被 spill 到磁碟的資料將會被重新載入記憶體中。此時,所有計算需要的資料已經都在記憶體中,真正的計算過程將啟動。當計算完成,Worker 將會把作業放到共享儲存空間中。這四種執行狀態的轉換關係見下圖。

image.png

執行控制

Mars Worker 通過 ExecutionActor 控制所有 Operand 在 Worker 中的執行。該 Actor 本身並不參與實際運算或者資料傳輸,只是向其他 Actor 提交任務。

Scheduler 中的 OperandActor 通過 ExecutionActor 上的 enqueue_graph 呼叫向 Worker 提交作業。Worker 接受 Operand 提交併且將其換存在佇列中。當作業可以執行時,ExecutionActor 將會向 Scheduler 傳送訊息,Scheduler 將確定是否將執行該操作。當 Scheduler 確定在當前 Worker 上執行 Operand,它將呼叫 start_execution 方法,並通過 add_finish_callback註冊一個回撥。這一設計允許執行結果被多個位置接收,這對故障恢復有價值。

ExecutionActor 使用 mars.promise 模組來同時處理多個 Operand 的執行請求。具體的執行步驟通過 Promise 類的 then 方法相串聯。當最終的執行結果被儲存,之前註冊的回撥將被觸發。如果在之前的任意執行步驟中發生錯誤,該錯誤會被傳導到最後 catch 方法註冊的處理函式中並得到處理。

Operand 的排序

所有在 READY 狀態的 Operand 都被提交到 Scheduler 選擇的 Worker 中。因此,在執行的絕大多數時間裡,提交到 Operand 的 Worker 個數通常都高於單個 Worker 能夠處理的 Operand 總數。因此,Worker 需要對 Operand 進行排序,此後選擇一部分 Worker 來執行。這一排序過程在 TaskQueueActor 中進行,該 Actor 中維護一個優先佇列,其中儲存 Operand 的相關資訊。與此同時,TaskQueueActor 定時執行一個作業分配任務,對處於優先佇列頭部的 Operand 分配執行資源直至沒有多餘的資源來執行 Operand,這一分配過程也會在新 Operand 提交或者 Operand 執行完成時觸發。

記憶體管理

Mars Worker 管理兩部分記憶體。第一部分是每個 Worker 程式私有的記憶體空間,由每個程式自己持有。第二部分是所有程式共享的記憶體空間,由 Apache Arrow 中的 plasma_store 持有。

為了避免程式記憶體溢位,我們引入了 Worker 級別的 QuotaActor,用於分配程式記憶體。當一個 Operand 開始執行前,它將為輸入和輸出 Chunk 向 QuotaActor 傳送批量記憶體請求。如果剩餘的記憶體空間可以滿足請求,該請求會被 QuotaActor 接受。否則,請求將排隊等待空閒資源。當相關記憶體使用被釋放,請求的資源會被釋放,此時,QuotaActor 能夠為其他 Operand 分配資源。

共享記憶體由 plasma_store 管理,通常會佔據整個記憶體的 50%。由於不存在溢位的可能,這部分記憶體無需經過 QuotaActor 而是直接通過 plasma_store 的相關方法進行分配。當共享記憶體使用殆盡,Mars Worker 會嘗試將一部分不在使用的 Chunk spill 到磁碟中,以騰出空間容納新的 Chunk。

從共享記憶體 spill 到磁碟的 Chunk 資料可能會被未來的 Operand 重新使用,而從磁碟重新載入共享記憶體的操作可能會非常耗費 IO 資源,尤其在共享記憶體已經耗盡,需要 spill 其他 Chunk 到磁碟以容納載入的 Chunk 時。因此,當資料共享並不需要時,例如該 Chunk 只會被一個 Operand 使用,我們會將 Chunk 直接載入程式私有記憶體中,而不是共享記憶體,這可以顯著減少作業總執行時間。

未來工作

Mars 目前正在快速迭代,近期將考慮實現 Worker 級別的 failover 及 shuffle 支援,Scheduler 級別的 failover 也在計劃中。


相關文章