使用ray扩展python应用之ray系统的设计细节

本文将探讨几个关键的分布式系统概念,比如故障容错、资源管理以及如何加速你的远程函数和Actor。这些细节对于分布式应用至关重要。

容错机制

故障容错确保即使出现意外——无论是用户代码出错、框架本身崩溃,还是运行机器宕机——系统也能尽可能地继续工作。Ray为不同场景设计了不同的容错机制。核心在于它的三大组件:全局控制存储GCS、分布式调度器和对象存储。但要注意一个硬伤:目前的Head Node是单点故障。所以,如果你的应用对高可用性要求极高,可能需要结合ZooKeeper等工具来实现更复杂的恢复逻辑。

在这里插入图片描述

上图清晰地展示了Ray的架构。我们可以把它分成两层:应用层和系统层。应用层就是我们直接打交道的部分,包括Driver、Worker、本地的对象存储和调度器。而系统层由GCS、全局调度器以及各种工具和服务组成。特别注意,除了GCS其他系统组件都是水平可扩展且无状态的。这意味着你可以轻松地增加更多节点来处理负载,而不用担心状态同步的问题。这种设计极大地简化了架构,并为故障恢复提供了便利。

关于GCS全局控制存储。可以把它想象成Ray的大脑,GCS集中管理着整个系统的状态信息,包括对象表、函数表、事件日志等等。内部实现上,它更像是一个带发布订阅功能的键值存储。虽然目前GCS是单点故障,运行在Head Node上,但这种设计有其巧妙之处。通过将所有状态都交给GCS管理,其他系统组件如调度器、对象存储都可以设计成无状态的。这意味着一旦某个组件失败,它只需要重启,然后从GCS读取最新的状态就能恢复,大大简化了故障恢复过程,并使得这些组件更容易独立扩展。

对于远程函数,由于它们本质上是无状态的,处理起来就相对简单粗暴了。如果一个远程函数执行失败,Ray会自动尝试重试,直到成功或者达到你设定的最大重试次数max_retries。

@ray.remote
def flaky_remote_fun(x):
	import random
	import sys
    if random.randint(0, 2) == 1:
        sys.exit(0)
    return x
r = flaky_remote_fun.remote(1)

以上例子中,flaky_remoteFun 函数有百分之五十的概率随机退出。当你调用 ray.get® 时,即使中间经历了几次失败,最终你仍然会得到正确的结果 x。这就是 Ray 对于无状态任务的简单而有效的容错机制。当然,你也可以通过 max_retries 参数来控制这个重试次数,根据你的需求调整。

相比远程函数,Actor的容错就复杂多了,因为它们是有状态的。Actor可能在初始化阶段、处理消息时,甚至两次消息之间发生失败。这里有个关键区别:如果Actor在处理一条消息时失败,Ray不会自动重试这条消息,而是会抛出一个RayActorError。但是,如果Actor在两次消息之间失败了,比如因为节点重启,Ray会在下次调用该Actor时尝试恢复它,最多重试max_retries次。这给了你一个机会去恢复Actor的状态。所以,Actor的容错效果很大程度上取决于你自己如何编写状态恢复代码

Ray object

Ray对象是Ray中处理数据的基本单元。你可以把它想象成一个容器,里面可以放任何能被序列化的东西,比如数字、字符串、列表、字典,甚至是对其他Ray对象的引用,也就是所谓的ObjectRef。这些ObjectRef就像是指向远程对象的唯一ID,有点像Future的概念。Ray对象通常在你创建任务时自动产生,比如任务返回值或者传递大型参数时。当然,你也可以手动用ray.put创建一个对象,比如o = ray.put(1),它会立即返回一个ObjectRef。

对象的owner是创建它的worker,owner通过引用计数来管理object的生命周期,以便于进行垃圾回收。

一般来说小型对象存储在进程的内存中,而大对象则由worker存储在ray系统中

Ray对象有一个非常重要的特性:不可变性。这意味着一旦一个对象被创建并放入对象存储,你就不能再修改它了。当你用 ray.get 获取一个对象时,实际上获取的是该对象的一个副本。你对这个副本做的任何修改,都不会影响到存储在对象存储中的原始对象。

尽管Ray尽力保证数据安全,但对象丢失还是可能发生。主要有两种情况:

  • 对象的所有者Worker节点挂了,导致引用计数丢失;
  • 所有持有该对象副本的节点都挂了。

默认情况下,如果你尝试获取一个已经丢失的对象,Ray会抛出一个ObjectLostError。不过,Ray提供了一个强大的容错机制——对象重建。通过在启动Ray时启用 enable_object_reconstruction等于True,当尝试访问一个丢失的对象时,Ray会尝试根据GCS中记录的信息,重新计算或重建这个对象。这个重建过程是惰性的,只有在真正需要的时候才进行,并且会受到之前提到的max_retries限制。

对象存储是Ray管理数据的地方,但它不是无限的。Ray使用引用计数来进行垃圾回收,清理掉程序不再需要的对象。但如果内存压力持续增大,对象存储满了怎么办?Ray会采取溢出策略,也就是把一些对象从内存复制到磁盘上,腾出空间。

if False:
    ray.init(num_cpus=20,
         _system_config={
            "min_spilling_size": 1024 * 1024,  # Spill at least 1MB
            "object_store_memory_mb": 500,
            "object_spilling_config": json.dumps(
                {"type": "filesystem", "params": {"directory_path": "/tmp/fast"}},
                )
             })
else:
    ray.init(num_cpus=20, runtime_env=runtime_env)

你可以通过配置参数来微调这个行为,比如 min_spilling_size 控制什么时候开始溢出,object_store_memory_mb 设置总内存大小。可以指定 object_spilling_config,让Ray把溢出的对象放到更快的存储设备上,比如SSD,而不是慢速的HDD,从而优化性能。

数据序列化

要想在不同的Worker进程之间传递数据和函数,序列化是必不可少的。无论是将数据存入对象存储,还是进行进程间通信IPC,甚至是实现故障容错,都离不开序列化。但要注意,并非所有东西都能被序列化。比如,一个打开的数据库连接,就没法直接序列化。

Ray内部使用了多种序列化技术,包括JSON、Arrow、Pickle以及Cloudpickle。选择合适的序列化方式,对于性能和兼容性至关重要。理解这些工具的特点,能帮助你更好地设计和优化你的Ray应用。Cloudpickle 是 Ray Python 生态中最重要的序列化工具之一。它主要用于序列化函数、Actor 以及大部分数据。相比 Python 内置的 pickle,cloudpickle 能够处理更多类型的对象,特别是那些在集群计算中常见的、需要跨进程传输的函数。如果你遇到某些自定义类无法被序列化的情况,比如类里包含了数据库连接,cloudpickle 也提供了机制让你注册自定义的序列化器和反序列化器,来处理这些特殊情况。但要注意,cloudpickle 有一个硬性要求:加载和读取的 Python 版本必须完全一致。这意味着你的所有 Ray 工作节点上的 Python 版本必须相同。

当处理大规模数据集时,Apache Arrow 就派上了用场。它是一种高效的列式内存格式,特别擅长处理结构化数据。Ray 在处理数据集时,会优先尝试使用 Arrow 进行序列化,因为它通常比 Cloudpickle 更节省空间,而且具有良好的跨语言兼容性。很多主流的数据处理和机器学习工具,比如 pandas、PySpark、TensorFlow 和 Dask,都支持 Arrow。这使得在不同语言或工具间交换数据变得更加高效。当然,Arrow 也有它的局限性,比如它不支持所有数据类型,像嵌套列这种 pandas 支持但 Arrow 不支持的类型。

gRPC 是 Ray 内部通信的基础框架。它是一个高性能的远程过程调用 RPC 框架,支持多种语言。Ray 使用 gRPC 来协调各个组件之间的通信。gRPC 本身使用 Protocol Buffers 作为序列化机制,对于小对象来说,速度非常快。而对于那些较大的对象,比如数据集,Ray 会先用 Arrow 或者 Cloudpickle 进行序列化,然后将结果放入对象存储,再通过 gRPC 传递 ObjectRef。gRPC 的优势在于其高性能和跨语言能力,这使得 Ray 能够在多种语言环境中高效运行。

资源管理

Ray 提供了灵活的资源管理方式。默认情况下,每个远程函数或 Actor 会请求 1 个 CPU 和 0 个 GPU。你可以通过 ray.remote 装饰器明确指定所需的资源,比如 num_cpus等于4, num_gpus等于2。

@ray.remote(num_cpus=4, num_gpus=2)

如果知道任务或actor所需的资源,也可以直接指定从而开启内存感知调度

@ray.remote(memory=500*1024*1024)

需要注意的是,这些资源请求通常是soft的,Ray 会尽力满足你的需求,但不保证一定能分配到那么多资源。

在内存使用方面,主要包括Ray 自身使用的内存(Ray 系统内存)和应用程序使用的内存(Ray 应用程序内存)。

在这里插入图片描述

Ray 的系统内存:

  • Redis,用于存储集群中存在的节点和参与者列表的内存。这些用途所使用的内存量通常很小。
  • Raylet,每个节点上运行的Raylet 进程所使用的内存。这无法控制,但通常很小。

Ray 应用程序内存:

  • Worker heap,应用程序的RSS减去其在 top 等命令中的共享内存使用量(SHR)
  • Object store memory,当应用程序通过 ray.put 在对象存储中创建对象以及从远程函数返回值时所使用的内存。当对象超出作用域时会被驱逐。每个节点上都运行着一个对象存储服务器。如果对象存储满了,对象会被溢写到磁盘。
  • Object store shared memory,当应用程序通过 ray.get 读取对象时所使用的内存。

资源扩展

Ray 支持两种主要的扩展方式:

  • 垂直扩展,就是使用配置更高规格的机器,比如配备更多 CPU、GPU 或更大内存的节点;
  • 水平扩展,就是增加更多的节点来分担工作负载。

自动伸缩器是Ray集群的智能管家,负责动态地管理Worker节点的数量。它能根据集群的需求自动启动新的节点,或者在节点闲置、配置变更或失败时终止或重启节点。触发自动伸缩的常见原因包括:

  • 启动worker,终止work,重启worker节点

  • 集群初始创建时需要达到最小节点数(min-nodes)

  • 有新的任务请求了额外的资源

  • Placement Group需要预分配资源,如果无法满足会创建新的worker

  • 通过SDK显式请求资源

Ray的自动伸缩器非常灵活,可以处理不同类型的节点,比如不同大小的云实例或者带有不同加速器的节点。

Placement Group,简称PG,是Ray中用来组织任务和预分配资源的强大工具。它的主要目的有

  • 提高数据局部性,把相关联的任务放在同一个节点上,减少数据传输开销(移动函数比移动数据更快);
  • 实现负载均衡,将任务分散到不同节点,提高系统可靠性;
  • 进行Gang Scheduling,确保一组任务或Actor能够同时启动,这对于需要同步初始化的场景很有用。

使用PG时,你需要定义一组资源束(resource bundle),每个bundle代表一个Worker所需的资源,然后选择一个放置策略,比如

  • STRICT PACK,所有资源集中在一个节点

  • PACK,尽量把所有资源放在一个节点

  • STRICT SPREAD,必须把它们分散到不同节点。

  • SPREAD,尽量把它们分散到不同节点。

一个 Placement Group 的生命周期大致分为这几个阶段。

  1. 创建,请求会发送给 GCS,GCS 会计算出一个最优的资源分配方案,并尝试原子性地预留资源。
  2. 分配,如果现有节点能满足需求,就分配成功;如果不行,自动伸缩器会尝试增加节点来满足需求。
  3. 节点故障,如果节点发生故障,GCS 会重新调度剩余的resource bundle,可能会导致部分重启。
  4. 清理,当创建这个 PG 的 Job 结束时,PG 会自动被移除。如果你想让 PG 在 Job 结束后仍然存在,可以设置 lifetime等于detached。

Ray 使用一种自下而上的分布式调度器来决定任务该在哪里运行,由一个全局调度器和每个节点的本地调度器组成

  • 本地调度,当一个新任务产生时,它会优先尝试在当前节点上运行,这有助于提高数据局部性,减少网络传输。如果本地节点资源不足,比如队列太长或者缺少特定资源如GPU,或者本地节点过载了,本地调度器就会求助于全局调度器。
  • 每个worker会周期性发送全局调度器心跳消息,汇报资源可用性和队列深度。
  • 全局调度器会查看所有可用节点,选择一个能让任务等待时间最短的节点。这个等待时间估计是基于节点当前的队列深度和任务所需输入数据的传输时间来计算的。
  • Ray 的调度器还包含一些优化,比如并行获取任务参数、提前处理本地对象、考虑节点资源不平衡以及依赖感知调度等。

命名空间是Ray提供的一个轻量级隔离机制,用于逻辑上分组你的Job和Actor。默认情况下,每个Ray程序运行在一个匿名的命名空间里,彼此隔离。如果你想让两个不同的程序共享同一个Actor,就需要将它们放在同一个命名空间下。这可以通过在ray.init时指定namespace参数来实现。

ray.init(namespace="my_project")

需要注意的是,命名空间提供的主要是逻辑隔离,而不是安全隔离。它不能阻止恶意程序互相干扰,但可以帮助你在开发和测试时更好地组织和管理你的应用。

Python 生态的繁荣离不开丰富的第三方库,但这也带来了依赖管理的挑战。Ray 提供了强大的 Runtime Environment 功能来解决这个问题。它支持 Conda 和 Virtualenv 两种环境管理方式。你可以动态地为每个 Worker 创建一个带有特定依赖的虚拟环境,然后启动 Worker。配置方式非常灵活,

可以直接指定 pip 包列表,或者指向一个 requirements.txt 文件,甚至可以使用 conda 来管理更复杂的依赖。你可以选择在全局范围内 ray.init 设置一个运行时环境

runtime_env = {"pip" : "requirements.txt"}

也可以为特定的远程函数或 Actor 单独指定。

不过要注意,对于那些需要大量原生代码编译的库,比如某些特定架构下的 TensorFlow,动态创建环境可能会比较慢,这时候建议预先准备好 Conda 环境。

除了直接连接到集群,Ray 还提供了一个 Job API,用于提交和管理 Job。这个 API 提供了一种更轻量级的方式来部署你的应用,尤其适合在 Kubernetes 等环境中使用。它避免了直接使用 Ray Client 可能遇到的库版本冲突和网络问题。Job API 主要有三个核心方法:

  • submit_job 用于提交一个新的 Job
  • get_job_status 用于查询 Job 的当前状态
  • get_job_logs 用于获取 Job 的日志。

例如以下示例

from ray.dashboard.modules.job.sdk import JobSubmissionClient
import time
from ray.dashboard.modules.job.common import JobStatus

client = JobSubmissionClient("<your Ray URL>")
job_id = client.submit_job(
    # Entrypoint shell command to execute
    entrypoint="python script_with_parameters.py --kwargs iterations=7",
    # Working dir
    runtime_env={
        "working_dir": ".",
        "pip": ["requests==2.26.0", "qiskit==0.34.2"],
        "env_vars": {"MY_VARIABLE": "foo"}
    }
)
print(f"Submitted job with ID: {job_id}")

while True:
    status = client.get_job_status(job_id)
    print(f"status: {status}")
    if status in {JobStatus.SUCCEEDED, JobStatus.STOPPED, JobStatus.FAILED}:
        break
    time.sleep(5)

logs = client.get_job_logs(job_id)
print(f"logs: {logs}")

一个 Job 请求通常包括你要执行的入口脚本、工作目录、以及所需的运行时环境。Job API 使用 HTTP 协议,这比 gRPC 更容易与 Kubernetes Ingress 等组件集成。

posted @ 2025-05-30 00:25  zhaojie10  阅读(30)  评论(0)    收藏  举报  来源