Ray: A Distributed Framework for Emerging AI Applications
Philipp Moritz, Robert Nishihara, Stephanie Wang, et al., arXiv:1712.05889v2, 2018
Ray 面向的不是单一的模型训练,而是强化学习(Reinforcement Learning,RL)中紧密耦合的仿真(simulation)、训练(training)和推理服务(serving)。这三类工作负载在执行时间、资源需求和状态管理方式上差异很大,并且会相互动态触发:
- 仿真通常是大量、细粒度、无状态或状态可重建的 CPU 任务;
- 训练通常是长时间、有状态、使用 GPU 的迭代计算;
- 推理服务需要在环境交互的闭环中提供低延迟决策;
- 仿真结果决定下一步要执行哪些任务,任务图不能在运行前完全确定。
传统 BSP 数据处理框架不擅长细粒度、异构和动态计算,深度学习框架又主要优化静态计算图内 的数值计算。Ray 的核心贡献,是在同一个动态执行引擎中统一 Task 和 Actor 两种抽象, 并通过分布式控制面支撑百万级任务吞吐。
一句话总结
Ray 把无状态计算表示为可重放的 Task,把有状态计算表示为串行执行方法的 Actor,并将二者 放进同一张动态任务图;系统再以 GCS 保存全局控制状态,以本地优先的两级调度降低调度开销, 以共享内存对象存储完成数据传递,从而同时获得灵活性、低延迟、水平扩展能力和基于 lineage 的容错。
问题背景:为什么已有框架不够用
一个典型 RL 训练循环先让当前策略与环境交互,生成若干 trajectory,再用这些数据更新策略:
当前策略
│
├── Serving:根据状态产生动作
│ │
│ ▼
├── Simulation:环境执行动作,产生状态和奖励
│ │
│ ▼
└── Training:汇总 trajectories,更新策略
│
└──────────► 下一版策略
这类应用给系统提出了四项关键要求:
- 细粒度与高吞吐:任务可能只运行几毫秒,大集群需要每秒调度数百万个任务;
- 异构性:任务耗时从毫秒到小时不等,资源可能是 CPU、GPU 或其他加速器;
- 同时支持无状态与有状态计算:仿真和数据处理适合无状态任务,模型副本、参数服务器及 第三方模拟器则需要保留状态;
- 动态执行:任务完成顺序不可预知,已有计算的结果还会决定后续要生成哪些任务。
MapReduce、Spark 一类 BSP 系统存在阶段屏障和较高的单任务开销;TensorFlow、MXNet 主要 解决计算图内部的分布式训练;CIEL、Dask 虽支持动态任务图,但论文当时认为它们缺少 Actor 和可水平扩展的控制面。将仿真、训练和服务分别放进多个专用系统,又会引入额外的数据搬运、 延迟和工程复杂度。
编程模型:Task 与 Actor 的统一
Ray 使用很小的一组异步 API 表达并行性和依赖关系:
| API | 语义 |
|---|---|
f.remote(args) | 异步执行远程函数,立即返回 future |
ray.get(futures) | 阻塞并取得 future 对应的对象 |
ray.wait(futures, k, timeout) | 在最先完成的 k 个结果就绪或超时后返回 |
Class.remote(args) | 创建远程 Actor,返回 Actor handle |
actor.method.remote(args) | 异步调用 Actor 方法,返回 future |
future 可以直接作为另一个远程调用的参数。因此,Driver 不必先 get 出结果再提交下游任务,运行时可以从 future 之间的关系直接得到数据依赖。远程函数还可以继续创建远程函数,即nested tasks;动态任务图由多个进程并行生成,而不是只能由单一 Driver 展开。
Task:无状态计算
Task 是无状态 Worker 对远程函数的一次执行。远程函数操作不可变对象,论文要求它无副作用,且输出只由输入决定,因此可以通过重新执行实现容错。
Task 的优势是:
- 可以在任务粒度做细粒度负载均衡;
- 可以把计算调度到输入数据所在节点,利用数据局部性;
- 中间结果丢失后可根据 lineage 重算,不需要保存 Worker 状态。
它的代价是频繁的小更新需要序列化和传输外部状态。
Actor:有状态计算
Actor 是一个显式创建的有状态进程。对同一个 Actor 的方法调用按顺序执行,后一个方法能看到前一个方法留下的内部状态。Actor handle 可以传给其他 Task 或 Actor。
Actor 适合:
- 参数服务器和模型副本;
- 反复访问驻留在 GPU 上的数据;
- 初始化成本高昂、内部状态难以序列化的第三方模拟器;
- 需要对小块内部状态频繁更新的计算。
Actor 的负载均衡粒度较粗,创建后也难以像 Task 一样迁移到大对象所在节点;发生故障时还需要依赖 checkpoint 控制恢复成本。
二者并非竞争关系,而是同一应用中的互补工具:
| 维度 | Task | Actor |
|---|---|---|
| 状态 | 无状态,输入输出不可变 | 进程内保留可变状态 |
| 负载均衡 | 细粒度 | 粗粒度 |
| 数据局部性 | 可逐任务选择节点 | Actor 固定后较弱 |
| 小更新 | 序列化开销较高 | 更新内部状态,开销低 |
| 故障恢复 | 根据 lineage 重算 | checkpoint 后重放方法 |
计算模型:动态任务图
Ray 把 Task、Actor 方法调用及其结果统一表示在一张运行时持续增长的图中。图中有三类边:
- data edge:Task 与其输入、输出对象之间的数据依赖;
- control edge:父 Task 创建 nested task 时产生的控制依赖;
- stateful edge:同一 Actor 上相邻方法调用之间的状态依赖。
stateful edge 是统一两种抽象的关键。它把 Actor 隐含的可变状态变化显式纳入 lineage,使 Actor 方法也能使用任务图的依赖追踪和重放机制。换言之,Actor 不是一套独立运行时,而是被嵌入无状态任务图中的一条有序状态链。
系统架构
论文中的 Ray 分为应用层与系统层:
应用层: Driver Stateless Workers Stateful Actors
│ │ │
└─────────────────┴───────────────────────┘
│
系统层: Per-node Local Scheduler
│ 仅在本地过载或资源不满足时上送
▼
Global Scheduler(s)
│
┌────────────────┴────────────────┐
▼ ▼
Global Control Store Distributed Object Store
lineage、对象位置、任务等 每节点共享内存、节点间复制
系统层由 GCS、分布式调度器和分布式对象存储三个核心组件组成。
Global Control Store:把状态与逻辑解耦
GCS 是带 pub-sub 能力的分片键值存储,保存系统完整的控制状态,包括:
- Task 和 Actor 的 lineage;
- 对象位置与大小;
- 注册的远程函数;
- 节点负载、事件日志等元数据。
论文实现中每个分片使用一个 Redis,并用 chain replication 提供容错。GCS 的关键不是简单地设置一个“中心数据库”,而是将控制状态的存储与使用这些状态作决策的组件解耦:
- lineage 不再依赖单个 Driver 或 Scheduler 的内存;
- Scheduler 不保存对象目录,数据传输不必经过 Scheduler;
- Scheduler、对象存储等组件自身可以保持无状态,失败后从 GCS 重建;
- 元数据存储和调度逻辑可以分别通过增加分片或副本水平扩展;
- 调试、性能分析和可视化工具可以查询同一份全局状态。
这是论文最重要的系统设计原则:逻辑上集中控制状态,物理上分片和复制;让其余组件尽量无状态化。它同时改善扩展性、容错性和可观测性。
Bottom-up 两级调度
Ray 使用每节点一个 Local Scheduler,加上一个或多个 Global Scheduler。新任务先提交给创建它的节点:
- Local Scheduler 在节点资源足够且未过载时直接本地执行;
- 节点缺少所需资源(例如 GPU)或本地队列超过阈值时,才把任务上送 Global Scheduler;
- Global Scheduler 从满足资源约束的节点中选择预计完成时间最短者。
其估价近似为:
预计等待时间 = 本地排队时间 + 远端输入传输时间
≈ 队列长度 × 平均任务耗时
+ 远端输入总大小 ÷ 平均网络带宽
节点队列与资源信息通过 heartbeat 获得,对象位置和大小从 GCS 获取,任务耗时与带宽用指数平均估计。大部分任务走本地短路径,只有需要全局协调的任务才进入 Global Scheduler,因此这种设计被称为 bottom-up。Global Scheduler 也可增加共享 GCS 状态的副本来扩展吞吐。
该设计在集中式调度的全局决策能力与纯 work stealing 的低开销之间取了折中:保留异构资源与数据局部性决策,同时避免让全局调度器成为每个任务的必经路径。
分布式内存对象存储
Task 的输入输出都放在分布式对象存储中。每个节点的对象存储使用共享内存和 Apache Arrow 格式,同节点 Worker 可以零拷贝读取对象;输入不在本地时,执行前把它复制到本地对象存储。
这里有几个重要设计选择:
- 对象不可变:无需处理更新冲突和复杂一致性协议,也便于 lineage 重算;
- 计算只访问本地对象:远端输入先复制,执行阶段不依赖远程读;
- 复制热点对象:避免单一数据源成为瓶颈;
- 内存优先:内存不足时按 LRU 淘汰到磁盘;
- 单对象不跨节点:一个对象必须能放入单节点内存,大矩阵等需由应用拆成多个 future。
对象目录存放在 GCS,而非调度器中,因此调度决策和数据传输彼此解耦。ray.get() 找不到本地对象时,可通过 GCS 的 pub-sub 等待对象位置出现,再从对应节点复制结果。
容错机制
Task 容错
GCS 持久保存 Task 的 lineage。当节点或对象丢失时,Ray 递归重新执行产生该对象的远程函数。这依赖无状态 Task 具有确定性、幂等性和无副作用;若用户在 Task 中直接修改外部系统,重放可能破坏这一假设。
Actor 容错
Actor 方法调用通过 stateful edge 记录在 lineage 中。Actor 故障后,Ray 从最近的用户定义 checkpoint 恢复状态,再按顺序重放后续方法。checkpoint 并非为了正确性所必需,但它决定了恢复时需要重放多少工作。
论文报告,在 2000 个 Actor 中故障恢复 400 个 Actor 时,checkpoint 将重放量从约 10000 次方法调用降到 500 次。相比 Task 的透明重算,Actor 仍把 checkpoint 策略的一部分责任留给应用开发者。
GCS 容错与空间控制
GCS 分片通过 chain replication 容忍节点故障。实验中,两副本链在成员退出、加入和状态传输期间,客户端观察到的最大延迟低于 30 ms。lineage 会随任务数持续增长,因此系统需要把旧记录定期 flush 到磁盘;论文也承认,对 lineage 进行安全垃圾回收仍是当时尚未完整解决的问题。
实验结论
论文的 benchmark 主要说明通用抽象没有牺牲关键路径性能:
| 实验 | 论文结果 | 说明 |
|---|---|---|
| 调度扩展性 | 100 节点超过 180 万 Task/s;1 亿空 Task 用时 54 s | 接近线性扩展,但属于高度并行的空任务上限测试 |
| 数据局部性 | 10–100 MB 输入下,无局部性感知的延迟高 1–2 个数量级 | Task 可以移动到数据所在节点 |
| 对象存储 | 大对象写吞吐超过 15 GB/s;小对象约 18K IOPS | 单客户端、16 核实例上的结果 |
| AllReduce | 16 节点、100 MB 约 200 ms,1 GB 约 1.2 s | 分别比当时 OpenMPI 快约 1.5× 和 2× |
| 分布式训练 | ResNet-101 吞吐与 Horovod 相当,比 Distributed TF 低 10% 以内 | Ray 负责外层编排,数值计算仍由 TensorFlow 完成 |
| 内嵌 Serving | 大输入吞吐 6900 states/s,Clipper 为 290 states/s | 客户端与服务同机,发挥共享内存优势,并非通用 Web Serving 对比 |
| 仿真 | 256 CPU 上 4.03M timesteps/s,BSP MPI 为 2.16M | 异步收集减少异构时长任务造成的屏障等待 |
| Evolution Strategies | 扩展到 8192 核,中位求解时间 3.7 分钟 | nested tasks 与 Actor 聚合树避免 Driver 瓶颈 |
| PPO | 所有规模均快于专用 MPI 实现,并减少 GPU 使用 | Task/Actor 级资源声明支持非对称 CPU/GPU 集群 |
其中 AllReduce 实验很能说明低延迟调度的重要性:人为给调度增加几毫秒延迟,迭代时间就接近翻倍。对于由许多短小、存在依赖关系的任务组成的通信原语,仅有高吞吐还不够,逐轮调度延迟同样会直接累积到端到端时间。
应用实验则体现了 Ray 的另一层价值:性能提升不只来自运行时更快,也来自编程模型允许开发者方便地改变应用结构。例如 ES 使用 Actor 构成分层聚合树,PPO 把 CPU 仿真和 GPU 训练放到非对称资源上;在固定架构的专用实现中,这类优化通常需要重写通信协议。
论文的边界与不足
- Ray 不是深度学习算子框架。它编排 TensorFlow 等系统完成训练,也不负责实现模拟器内部逻辑;
- Ray 不是 Spark 的替代品。论文版本缺少查询优化、straggler mitigation 和丰富的数据处理 API;
- Ray 的内嵌 Serving 不等于完整模型服务平台。模型版本管理、测试和模型组合仍是Clipper、TensorFlow Serving 一类系统的职责;
- 动态性限制了全局优化。Scheduler 无法预先看到完整计算图,需要依赖运行时统计做近似决策;
- lineage 有空间成本。长生命周期、超高任务量应用需要 flush 和垃圾回收;
- Actor 弱化了位置灵活性。状态驻留提升了小更新效率,也使数据局部性和故障恢复更复杂;
- 部分对比有特定前提。例如 Clipper 实验是同机内嵌调用,MPI 仿真对比使用带屏障的 BSP 实现,180 万 Task/s 则来自空任务,不能直接外推到所有真实负载。
此外,这篇笔记讨论的是 2018 年论文中的架构。论文中的 Redis 分片、chain replication 和具体组件边界不应直接等同于后续版本的 Ray 实现。
我的理解
这篇论文真正有影响力的地方,不是单独发明了 future、Actor、lineage 或两级调度,而是根据 AI 应用的组合型负载,把这些已有思想放进了一套一致的执行语义中:
- Task 提供可移动、可重算的函数式计算;
- Actor 提供状态驻留和低成本迭代;
- future 与动态任务图负责组合二者;
- 对象存储承担数据面;
- GCS 承担可扩展、可查询的控制状态;
- bottom-up 调度让常见路径保持本地化,只在必要时进行全局协调。
因此,Ray 的核心抽象并不局限于 RL。任何同时包含细粒度并行、长生命周期状态、动态控制流和异构资源的工作流,都可能从这种 Task + Actor 的组合中受益。论文留下的长期设计启示是:
面对复杂分布式应用,与其为每种子负载拼接一个专用系统,不如先寻找能够统一表达这些负载、同时仍保留关键性能特征的最小抽象集合。