快捷方式

遠端參考協定

本筆記描述了遠端參考協定的設計細節,並逐步介紹了不同場景中的訊息流程。在繼續之前,請確保您熟悉分散式 RPC 框架

背景

RRef 代表 Remote REFerence(遠端參考)。它是一個指向位於本地或遠端 worker 上的物件的參考,並且在底層透明地處理參考計數。從概念上講,它可以被視為一個分散式共享指標。應用程式可以透過呼叫 remote() 來建立 RRef。每個 RRef 都由 remote() 呼叫的被呼叫方 worker(即擁有者)所擁有,並且可以被多個使用者使用。擁有者儲存真實資料並追蹤全域參考計數。每個 RRef 都可以透過一個全域 RRefId 來唯一識別,該 RRefIdremote() 呼叫的呼叫方建立時被分配。

在擁有者 worker 上,只有一個 OwnerRRef 實例,它包含真實資料,而在使用者 worker 上,可以有任意數量的 UserRRefs,並且 UserRRef 不持有資料。在擁有者上的所有使用都將使用全域唯一的 RRefId 來檢索唯一的 OwnerRRef 實例。當 UserRRefrpc_sync()rpc_async()remote() 呼叫中被用作參數或傳回值時,將會建立一個 UserRRef,並且將會通知擁有者以更新參考計數。當全域沒有 UserRRef 實例,並且在擁有者上也沒有對 OwnerRRef 的參考時,OwnerRRef 及其資料將會被刪除。

假設

RRef 協定是基於以下假設設計的。

  • 暫時性網路故障:RRef 設計透過重試訊息來處理暫時性網路故障。它無法處理節點崩潰或永久性網路分割。當這些事件發生時,應用程式應關閉所有 worker,恢復到先前的檢查點,然後恢復訓練。

  • 非冪等的 UDF:我們假設提供給 rpc_sync()rpc_async()remote() 的使用者函數 (UDF) 不是冪等的,因此無法重試。但是,內部 RRef 控制訊息是冪等的,並且在訊息失敗時會重試。

  • 亂序訊息傳遞:我們不假設任何一對節點之間的訊息傳遞順序,因為傳送方和接收方都在使用多個執行緒。無法保證哪個訊息將首先被處理。

RRef 生存期

該協定的目標是在適當的時間刪除一個 OwnerRRef。刪除 OwnerRRef 的正確時間是當沒有存活的 UserRRef 實例,並且使用者程式碼也沒有持有對 OwnerRRef 的參考時。棘手的部分是確定是否有任何存活的 UserRRef 實例。

設計原理

使用者可以在三種情況下獲得 UserRRef

  1. 從擁有者接收 UserRRef

  2. 從另一個使用者接收 UserRRef

  3. 建立一個由另一個 worker 擁有的新 UserRRef

情況 1 最簡單,擁有者將其 RRef 傳遞給使用者,其中擁有者呼叫 rpc_sync()rpc_async()remote() 並將其 RRef 用作參數。在這種情況下,將會在使用者上建立一個新的 UserRRef。由於擁有者是呼叫方,因此它可以輕鬆更新其在 OwnerRRef 上的本地參考計數。

唯一的要求是任何 UserRRef 都必須在銷毀時通知擁有者。因此,我們需要第一個保證

G1. 當任何 UserRRef 被刪除時,擁有者將會收到通知。

由於訊息可能延遲或亂序到達,我們需要另一個保證來確保刪除訊息不會太早被處理。如果 A 向 B 發送一個涉及 RRef 的訊息,我們將 A 上的 RRef 稱為父 RRef,將 B 上的 RRef 稱為子 RRef。

G2. 在擁有者確認子 RRef 之前,父 RRef 不會被刪除。

在情況 2 和 3 中,擁有者可能只有關於 RRef 分叉圖的部分或完全沒有資訊。例如,可以在使用者上建構一個 RRef,並且在擁有者收到任何 RPC 呼叫之前,建立者使用者可能已經與其他使用者共享了該 RRef,並且這些使用者可能會進一步共享該 RRef。一個不變量是,任何 RRef 的分叉圖始終是一棵樹,因為分叉 RRef 總是在被呼叫者上建立一個新的 UserRRef 實例(除非被呼叫者是擁有者),因此每個 RRef 都有一個父項。

擁有者對樹中任何 UserRRef 的視圖都有三個階段

1) unknown -> 2) known -> 3) deleted.

擁有者對整個樹的視圖不斷變化。當擁有者認為沒有存活的 UserRRef 實例時,它會刪除其 OwnerRRef 實例,也就是說,當 OwnerRRef 被刪除時,所有 UserRRef 實例可能確實被刪除或未知。危險的情況是當一些分叉是未知的,而另一些分叉已被刪除時。

G2 簡單地保證了在擁有者知道其所有子 UserRRef 實例之前,沒有父 UserRRef 可以被刪除。但是,子 UserRRef 可能在擁有者知道其父 UserRRef 之前被刪除。

考慮以下範例,其中 OwnerRRef 分叉到 A,然後 A 分叉到 Y,而 Y 分叉到 Z。

OwnerRRef -> A -> Y -> Z

如果 Z 的所有訊息(包括刪除訊息)都在 Y 的訊息之前由擁有者處理,則擁有者會在知道 Y 存在之前得知 Z 已被刪除。然而,這不會造成任何問題。因為至少 Y 的一個祖先 (A) 將會存活,並且它會阻止擁有者刪除 OwnerRRef。更具體地說,如果擁有者不知道 Y,則由於 G2,A 無法被刪除,並且擁有者知道 A,因為它是 A 的父節點。

如果 RRef 是在使用者上建立的,事情會變得有點棘手。

OwnerRRef
    ^
    |
    A -> Y -> Z

如果 Z 在 UserRRef 上呼叫 to_here(),則當 Z 被刪除時,擁有者至少知道 A,因為否則 to_here() 將無法完成。如果 Z 沒有呼叫 to_here(),則擁有者有可能在收到來自 A 和 Y 的任何訊息之前收到來自 Z 的所有訊息。在這種情況下,由於 OwnerRRef 的實際資料尚未建立,因此也沒有任何東西需要刪除。這與 Z 根本不存在相同。因此,仍然沒問題。

實作

G1 是透過在 UserRRef 解構函式中發送刪除訊息來實作的。為了提供 G2,父 UserRRef 會在每次分叉時被放入一個上下文中,並以新的 ForkId 進行索引。父 UserRRef 只有在收到來自子節點的確認訊息 (ACK) 時才會從上下文中移除,並且子節點只有在獲得擁有者的確認後才會發送 ACK。

協定情境

現在讓我們討論上述設計如何在四種情境中轉換為協定。

使用者與擁有者分享 RRef 作為回傳值

import torch
import torch.distributed.rpc as rpc

# on worker A
rref = rpc.remote('B', torch.add, args=(torch.ones(2), 1))
# say the rref has RRefId 100 and ForkId 1
rref.to_here()

在這種情況下,UserRRef 在使用者工作節點 A 上建立,然後與遠端訊息一起傳遞給擁有者工作節點 B,然後 B 建立 OwnerRRefremote() 方法立即回傳,這意味著 UserRRef 可以在擁有者知道之前被分叉/使用。

在擁有者端,當接收到 remote() 呼叫時,它將建立 OwnerRRef,並回傳一個 ACK 以確認 {100, 1}RRefIdForkId)。只有在收到此 ACK 後,A 才能刪除其 UserRRef。這涉及 G1G2G1 是顯而易見的。對於 G2OwnerRRefUserRRef 的子節點,並且 UserRRef 在收到來自擁有者的 ACK 之前不會被刪除。

user_to_owner_ret.png

上圖顯示了訊息流,其中實線箭頭包含使用者函數,虛線箭頭是內建訊息。請注意,從 A 到 B 的前兩個訊息(remote()to_here())可能會以任何順序到達 B,但只有在以下情況下才會發送最終的刪除訊息:

  • B 確認 UserRRef {100, 1} (G2),並且

  • Python GC 同意刪除本機 UserRRef 實例。當 RRef 不再在範圍內並且符合垃圾回收的條件時,就會發生這種情況。

使用者與擁有者分享 RRef 作為引數

import torch
import torch.distributed.rpc as rpc

# on worker A and worker B
def func(rref):
  pass

# on worker A
rref = rpc.remote('B', torch.add, args=(torch.ones(2), 1))
# say the rref has RRefId 100 and ForkId 1
rpc.rpc_async('B', func, args=(rref, ))

在這種情況下,在 A 上建立 UserRRef 後,A 在後續的 RPC 呼叫中使用它作為 B 的引數。A 將保持 UserRRef {100, 1} 存活,直到它收到來自 B 的確認 (G2,而不是 RPC 呼叫的回傳值)。這是必要的,因為 A 不應該在收到所有先前的訊息之前發送刪除訊息,否則,OwnerRRef 可以在使用之前被刪除,因為我們不保證訊息傳遞順序。這是透過建立 RRef 的子 ForkId 來完成的,將它們保存在一個 map 中,直到收到擁有者確認子 ForkId。下圖顯示了訊息流。

user_to_owner_arg.png

請注意,UserRRef 可以在 func 完成甚至開始之前在 B 上被刪除。但是,這是可以的,因為在 B 發送子 ForkId 的 ACK 時,它已經獲得了 OwnerRRef 實例,這將防止它太快被刪除。

擁有者與使用者分享 RRef

擁有者到使用者是最簡單的情況,擁有者可以在本機更新參考計數,並且不需要任何額外的控制訊息來通知其他人。關於 G2,它與父節點立即收到來自擁有者的 ACK 相同,因為父節點就是擁有者。

import torch
import torch.distributed.rpc as RRef, rpc

# on worker B and worker C
def func(rref):
  pass

# on worker B, creating a local RRef
rref = RRef("data")
# say the rref has RRefId 100
dist.rpc_async('C', func, args=(rref, ))
owner_to_user.png

上圖顯示了訊息流程。請注意,當 OwnerRRef 在 rpc_async 呼叫後超出作用域時,它不會被刪除,因為內部有一個 map 來保持它的存活,如果存在任何已知的 forks,在這種情況下是 UserRRef {100, 1}。(G2

使用者分享 RRef 給使用者

這是最複雜的情況,呼叫方使用者(父 UserRRef)、被呼叫方使用者(子 UserRRef)和擁有者都需要參與。

import torch
import torch.distributed.rpc as rpc

# on worker A and worker C
def func(rref):
  pass

# on worker A
rref = rpc.remote('B', torch.add, args=(torch.ones(2), 1))
# say the rref has RRefId 100 and ForkId 1
rpc.rpc_async('C', func, args=(rref, ))
user_to_user.png

當 C 從 A 接收到子 UserRRef 時,它會向擁有者 B 發送 fork 請求。稍後,當 B 確認 C 上的 UserRRef 時,C 將並行執行兩個動作:1) 向 A 發送子 ACK,以及 2) 執行使用者提供的函數。在此期間,父節點 (A) 將保持其 UserRRef {100, 1} 的存活狀態,以實現 G2

文件

存取 PyTorch 的完整開發人員文件

檢視文件

教學課程

取得針對初學者和進階開發人員的深入教學課程

檢視教學課程

資源

尋找開發資源並獲得您的問題解答

檢視資源