远程引用协议¶
本文介绍了远程引用协议(Remote Reference protocol)的设计细节,并详细讲解了不同场景下的消息流。在继续之前,请确保您熟悉分布式 RPC 框架。
背景¶
RRef 是 Remote REFerence 的缩写。它是位于本地或远程工作器上的对象的引用,并在底层透明地处理引用计数。概念上,它可以被视为一个分布式共享指针。应用程序可以通过调用 remote()
创建 RRef。每个 RRef 都由 remote()
调用的被调用方工作器(即所有者,owner)拥有,并可供多个用户使用。所有者存储实际数据并跟踪全局引用计数。每个 RRef 都可以通过一个全局唯一的 RRefId
来唯一标识,该 ID 在 remote()
调用的调用方创建时分配。
在所有者工作器上,只有一个 OwnerRRef
实例,其中包含实际数据,而在用户工作器上,可以根据需要有任意数量的 UserRRef
实例,且 UserRRef
不持有数据。所有在所有者上的使用都将通过全局唯一的 RRefId
检索唯一的 OwnerRRef
实例。当 UserRRef
在 rpc_sync()
、rpc_async()
或 remote()
调用中用作参数或返回值时,就会创建一个 UserRRef
,并且所有者会收到通知以更新引用计数。当全局没有 UserRRef
实例且所有者上也没有对 OwnerRRef
的引用时,OwnerRRef
及其数据将被删除。
假设¶
RRef 协议基于以下假设设计。
瞬态网络故障:RRef 设计通过重试消息来处理瞬态网络故障。它无法处理节点崩溃或永久性网络分区。当这些事件发生时,应用程序应关闭所有工作器,回滚到先前的检查点,然后恢复训练。
非幂等用户自定义函数 (UDF):我们假设提供给
rpc_sync()
、rpc_async()
或remote()
的用户自定义函数 (UDF) 是非幂等的,因此不能重试。然而,内部 RRef 控制消息是幂等的,并在消息失败时重试。消息乱序交付:我们不假定任意一对节点之间的消息交付顺序,因为发送方和接收方都使用多线程。无法保证哪条消息将首先被处理。
RRef 生命周期¶
该协议的目标是在适当的时间删除 OwnerRRef
。删除 OwnerRRef
的正确时机是当没有存活的 UserRRef
实例,并且用户代码也没有持有对 OwnerRRef
的引用时。棘手之处在于确定是否存在任何存活的 UserRRef
实例。
设计原理¶
用户可以通过三种情况获得 UserRRef
从所有者那里接收
UserRRef
。从另一个用户那里接收
UserRRef
。创建一个由另一个工作器拥有的新的
UserRRef
。
情况 1 最简单,所有者将其 RRef 传递给用户,所有者调用 rpc_sync()
、rpc_async()
或 remote()
并将其 RRef 用作参数。在这种情况下,将在用户工作器上创建一个新的 UserRRef
。由于所有者是调用方,它可以轻松更新其在 OwnerRRef
上的本地引用计数。
唯一的要求是任何 UserRRef
在销毁时必须通知所有者。因此,我们需要第一个保证
G1. 当任何 UserRRef 被删除时,所有者将收到通知。
由于消息可能延迟或乱序到达,我们需要另一个保证来确保删除消息不会处理得太早。如果 A 发送一条涉及 RRef 的消息给 B,我们将 A 上的 RRef 称为父 RRef,将 B 上的 RRef 称为子 RRef。
G2. 父 RRef 在子 RRef 被所有者确认之前不会被删除。
在情况 2 和 3 中,所有者可能对 RRef 的分叉图(fork graph)仅有部分了解或完全不了解。例如,可以在用户工作器上构建一个 RRef,并且在所有者收到任何 RPC 调用之前,创建该 RRef 的用户可能已经将 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,A 由于 **G2** 不会被删除,而且所有者知道 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。
协议场景¶
现在我们讨论上述设计在四种场景下如何转化为协议。