渐进式容错:监督树与 supervise
在大规模集群中,硬件故障不是"是否"发生,而是"何时"发生。Monarch 借鉴 Erlang 的监督树(Supervision Tree)思想,提供了一套有原则、可定制的容错机制。
1. 所有权原则:一切故障必有归宿
Monarch 对资源(host/proc/actor 网格)的所有权有四条铁律:
- 创建即拥有:持有 ProcMesh 引用去 spawn Actor,得到的就是一个"被拥有的"ActorMesh;
- 至多一个所有者:不支持所有权转移或挂起;一个 Actor 可以拥有多个资源;
- 网格不能超越所有者:所有者停止或死亡,其拥有的网格随之停止;
- 停止的网格不再收新消息:但会排空已入队的消息,然后执行清理;被拥有的网格先于所有者清理。
由此推导出:所有权构成严格的树。从任何网格都能画出一条通往某个"根"的路径——系统里不存在孤儿对象,所有故障都必须传播。
2. 默认行为:故障沿树向上传播
当一个 Actor 失败时,失败传播给它的父级;父级可以处理,不处理则父级自己也失败,继续向上传播;到达根(你的客户端脚本)仍未处理,就触发用户故障处理器——默认退出进程。
这个默认行为看似粗暴,实则经过深思:它避免了"线程/进程悄悄死掉、留下死锁的其余部分"这类 Unix 默认行为中常见的意外死锁。
期望响应的调用:错误直达调用方
如果你用 call_one 调用并等待结果,异常会直接路由给你:
class Errorful(Actor):
@endpoint
def say_hello(self):
raise Exception("I don't want to")
e = this_proc().spawn("errorful", Errorful)
try:
e.say_hello.call_one().get()
except Exception:
print("It didn't say hello")不期望响应的调用:错误交给所有者
broadcast 发出的消息没有响应通道,错误会作为失败事件送达该 Actor 的所有者,进入监督流程。
3. 定制恢复:supervise 方法
细粒度恢复的关键 API。如果 Actor 定义了 __supervise__ 特殊方法,它所拥有网格的失败事件会交给它处理:
from monarch.actor import Actor, MeshFailure
class SupervisorActor(Actor):
def __init__(self, worker_procs: ProcMesh):
# "workers" 网格由本 Actor 拥有;本 Actor 失败时它也会失败
self.workers = worker_procs.spawn("workers", WorkerActor)
def __supervise__(self, failure: MeshFailure) -> bool:
# failure 可字符串化,包含错误信息、Actor 名称,
# 以及(能确定时)失败的 rank
print(f"Failure received: {failure}")
# 返回真值 → 事件视为已处理,不再向上传播
# 返回假值 → 传播给本 Actor 的创建者(spawn 它的那一方)
return True同步还是异步?
__supervise__ 可以声明为 def 或 async def,但有约束:端点全是 def 的 Actor 必须用 def 的 __supervise__。async def 版本运行在 Actor 的 asyncio 事件循环上,可以 await 其他 endpoint 或 I/O,例如:
async def __supervise__(self, failure: MeshFailure) -> bool:
await self.restart_failed_mesh(failure) # 等待重启端点完成
return True时序细节(写恢复逻辑前必读)
__supervise__在 Actor 的事件循环上以独立任务运行,一次处理一个失败,pending 期间 Actor 继续处理消息;- 它可能在任何
await挂起点插入执行——哪怕是asyncio.sleep或与本故障无关的调用。await之后请重新检查它改动的状态; - 同步代码不会被中断:sync endpoint 跑完才轮到 sync
__supervise__; - 对 N 个 Actor 的网格,
__supervise__可能被调用 1 到 N 次之间的任意次数,不保证次数; - 如果
__supervise__抛异常,视为一个新的监督事件(携带原因链)向上传播——语义与上下文管理器的__exit__一致。
4. 最后一道防线:unhandled_fault_hook
如果整棵监督树都没能处理一个 MeshFailure,它会到达客户端,此时 monarch.actor.unhandled_fault_hook 被调用(默认行为:以 KeyboardInterrupt 退出进程)。你可以全局替换这个钩子:
import monarch.actor
def my_unhandled_fault_hook(failure: MeshFailure) -> None:
# 记日志、上报指标……
print(f"Mesh failure was not handled: {failure}")
# 不抛异常 = 视为已处理;抛异常 = 未处理
# pyre-ignore[9]
monarch.actor.unhandled_fault_hook = my_unhandled_fault_hook5. 实战模式:分层恢复
把上面的机制组合起来,就是一套渐进式容错策略:
class Manager(Actor):
"""中间管理者:能修的小故障就地修,修不了再上报。"""
def __init__(self, worker_procs: ProcMesh):
self.worker_procs = worker_procs
self.workers = worker_procs.spawn("workers", Worker)
def __supervise__(self, failure: MeshFailure) -> bool:
if self.is_recoverable(failure):
# 例如 DataLoader 读 URL 失败 → 原地重启该 Actor
self.restart_worker(failure)
return True # 止损于此,不惊动上层
return None # 交给上层(客户端)决定- 叶子层:
call里的 try-except 处理可预期的业务异常; - 管理者层:
__supervise__处理可恢复的网格故障(重启 Actor、换节点重拉); - 客户端层:
unhandled_fault_hook兜底,决定整个作业的存亡。
这种分层对于在廉价抢占式实例(Spot Instances)上训练尤其宝贵:单点故障被管理者吸收,作业不停。
关键点
- 所有权即监督责任:严格的树结构保证所有故障必有传播路径;
- 默认快速失败,避免静默死锁;
call的异常直达调用方,broadcast的故障交给所有者; __supervise__(failure) -> bool是细粒度恢复的入口,返回真值即止损;unhandled_fault_hook是全局兜底,可替换默认的退出行为;- 不保证调用次数、可能在任意
await处插入——恢复逻辑要写得幂等且防御。
