Python Ray 扩展指南(一)
原文:
annas-archive.org/md5/95872ff5b3ec96901f7e3cfb51cd271f译者:飞龙
前言
在本书中,Holden Karau 和 Boris Lublinksy 探讨了当今计算领域最重要的趋势:对可扩展计算需求的增长。这一趋势在很大程度上受到机器学习(ML)在许多行业的普及和实际应用中所需的计算资源数量的增加驱动。
过去的十年见证了计算性质的显著变化。在 2012 年,当我首次涉足 ML 时,大部分工作都是在单个笔记本电脑或服务器上进行的,并且许多从业者在使用 Matlab。那一年可以说是一个拐点,因为深度学习以惊人的优势赢得了 ImageNet 竞赛。这导致了多年来持续的趋势,即越来越多的计算在越来越多的数据上取得了更好的结果。这一趋势尚未显示出减缓的迹象,而且在最近几年随着大语言模型的出现而加速。
从小数据和小模型转向大数据和大模型,这改变了 ML 的实践。软件工程现在在 ML 中扮演着核心角色,成功利用 ML 的团队和组织通常会建立大型内部基础设施团队,以支持跨数百或数千台机器扩展 ML 应用所需的分布式系统。
因此,尽管 ML 在其功能方面正在增长,并且对各种企业越来越相关,但由于进行 ML 所需的重要基础设施投资,它也变得越来越难以实现。
要使每个企业能够利用并从 ML 中获得价值,我们需要使其在实践中应用变得更加容易。这意味着消除开发人员成为分布式系统和基础设施专家的需求。
使可扩展计算和可扩展 ML 变得易于操作,这是 Ray 的目标,也是我们首次创建 Ray 的原因。这是计算进步的自然延续。几十年前,开发人员必须使用汇编语言和其他低级机器语言来构建应用程序,因此能够执行低级内存优化和其他操作的人才被认为是最优秀的开发者。这使得软件开发难以进行,并限制了能够构建应用程序的人数。今天,很少有开发人员会考虑汇编语言。它不再是应用程序开发的关键路径,因此如今有更多的人可以开发应用程序并构建出色的产品。
基础设施也将面临同样的情况。今天,为了扩展 Python 应用程序和扩展 ML 应用程序的基础设施建设和管理已经成为进行 ML 和构建可扩展应用程序和产品的关键路径。然而,基础设施将走上汇编语言的道路。当这种情况发生时,将会打开大门,更多的人将会构建这些类型的应用程序。
Scaling Python and Ray 可作为任何希望实践 ML 或希望构建下一代可扩展产品和应用程序的人的入门指南。它涵盖了多个重要 ML 模式的扩展,从深度学习到超参数调整再到强化学习。它涉及了扩展数据摄取和预处理的最佳实践。它介绍了构建可扩展应用程序的基础知识。重要的是,它还介绍了 Ray 如何融入更广泛的 ML 和计算生态系统中。
希望你喜欢阅读这本书!它将使你能够理解计算领域最大的趋势,并为你提供工具,帮助你在应用 ML 到业务中或构建下一个伟大的产品和应用程序时利用和导航这一趋势。
Robert Nishihara
Ray 的共同创始人;Anyscale 的联合创始人兼首席执行官
2022 年 11 月,旧金山
序言
我们为那些希望在 Python 中构建和扩展应用程序而不成为系统管理员的开发人员和数据科学家编写了本书。我们期望本书对于那些处理从单线程解决方案到多线程解决方案,再到分布式计算的问题的复杂性和规模不断增长的个人和团队最为有益。
虽然你可以在 Java 中使用 Ray,但本书使用 Python,并假设你对 Python 生态系统有一般的了解。如果你对 Python 不熟悉,优秀的 O’Reilly 书籍包括《学习 Python》(Mark Lutz 著)和《Python 数据分析》(Wes McKinney 著)。
无服务器 是一个有点炒作的词,尽管它的名字是这样,但无服务器模型确实涉及相当多的服务器,但这个想法是你不必显式地管理它们。对于许多开发人员和数据科学家来说,不用担心服务器的细节就能让事情神奇地扩展的承诺是相当诱人的。另一方面,如果你喜欢深入研究你的服务器、部署机制和负载均衡器,那么这可能不适合你——但希望你会向同事推荐本书。
你将学到什么
在阅读本书时,您将学习如何利用您现有的 Python 技能使程序扩展到超越单个计算机的规模。您将学习有关分布式计算的技术,从远程过程调用到 actors,一直到分布式数据集和机器学习。我们在附录 A 中以一个“真实的”示例结束了本书,该示例使用了许多这些技术来构建可扩展的后端,并与基于 Python 的 Web 应用程序集成,并在 Kubernetes 上部署。
关于责任的说明
俗话说,大权必责。Ray 及其类似工具使您能够构建处理更多数据和用户的更复杂系统。重要的是不要过于兴奋和沉迷于解决问题,因为它们很有趣,要停下来问问自己的决定会带来什么影响。
寻找关于善意的工程师和数据科学家意外构建导致灾难性影响的模型或工具的故事并不难,比如破坏了新的美国退伍军人事务部支付系统,或者歧视性别的招聘算法。我们要求你在使用你的新发现的力量时牢记这一点,因为谁也不想因为错误的原因而进入教科书。
本书中使用的约定
本书中使用以下印刷约定:
斜体
表示新术语、URL、电子邮件地址、文件名和文件扩展名。
等宽字体
用于程序清单,以及在段落内用于指代程序元素,如变量或函数名、数据库、数据类型、环境变量、语句和关键字。
等宽斜体
显示应由用户提供的值或由上下文确定的值。
提示
此元素表示一个提示或建议。
注意
此元素表示一般说明。
警告
此元素指示警告或注意事项。
许可
一旦在印刷版中发布,不包括 O’Reilly 的独特设计元素(例如封面艺术、设计格式、“外观和感觉”)或 O’Reilly 的商标、服务标记和商业名称,本书可根据 知识共享署名-非商业性使用-禁止演绎 4.0 国际公共许可证 使用。我们感谢 O’Reilly 允许我们在 Creative Commons 许可下提供本书。我们希望您选择通过公司费用账户购买数本此书(它是即将到来的任何假期的极好礼物)以支持本书(及其作者)。
使用代码示例
使用 Ray 扩展 Python 机器学习 GitHub 代码库包含本书大部分示例。本书中大多数示例位于 ray_examples 目录中。与 Dask on Ray 相关的示例位于 dask 目录中,而使用 Spark on Ray 的示例位于 spark 目录中。
如果您有技术问题或使用代码示例时遇到问题,请发送电子邮件至 bookquestions@oreilly.com。
本书旨在帮助您完成工作。一般来说,如果本书提供示例代码,则可以在您的程序和文档中使用它。除非您复制了大量代码,否则无需征得我们的许可。例如,编写使用本书中几个代码块的程序不需要许可。出售或分发来自 O’Reilly 书籍的示例需要许可。引用本书并引用示例代码回答问题不需要许可。将本书中大量示例代码整合到您产品的文档中需要许可。
我们感谢,但通常不要求署名。署名通常包括标题、作者、出版商和 ISBN。例如:“使用 Ray 扩展 Python 由 Holden Karau 和 Boris Lublinsky(O’Reilly)编写。版权所有 2023 Holden Karau 和 Boris Lublinsky,978-1-098-11880-8。”
如果您认为您使用的代码示例超出了公平使用或上述许可的范围,请随时通过 permissions@oreilly.com 联系我们。
O’Reilly 在线学习
注意
超过 40 年来,O’Reilly Media 提供技术和商业培训、知识和见解,帮助公司取得成功。
我们独特的专家和创新者网络通过书籍、文章和我们的在线学习平台分享他们的知识和专业知识。O’Reilly 的在线学习平台为您提供按需访问的实时培训课程、深入学习路径、交互式编码环境以及来自 O’Reilly 和其他 200 多家出版商的大量文本和视频。欲了解更多信息,请访问:https://oreilly.com。
如何联系我们
请将有关本书的评论和问题发送给出版商:
-
O’Reilly Media, Inc.
-
1005 Gravenstein Highway North
-
Sebastopol, CA 95472
-
800-998-9938(美国或加拿大)
-
707-829-0515(国际或本地)
-
707-829-0104(传真)
我们为这本书创建了一个网页,列出勘误、示例和任何额外信息。你可以访问这个页面:https://oreil.ly/scaling-python-ray。
发送邮件至bookquestions@oreilly.com 对本书发表评论或提出技术问题。
要获取关于我们的书籍和课程的新闻和信息,请访问:https://oreilly.com。
在 LinkedIn 上找到我们:https://linkedin.com/company/oreilly-media。
在 Twitter 上关注我们:https://twitter.com/oreillymedia。
观看我们的 YouTube 频道:https://youtube.com/oreillymedia。
致谢
我们要感谢 Carlos Andrade Costa 的贡献,他与我们共同撰写了第八章。如果没有构建在社区基础上,本书将不会存在。感谢 Ray/Berkeley 社区和 PyData 社区。感谢所有早期读者和评论者对你们的贡献和指导。这些评论者包括 Dean Wampler、Jonathan Dinu、Adam Breindel、Bill Chambers、Trevor Grant、Ruben Berenguel、Michael Behrendt 等等。特别感谢 Ann Spencer 对最终成为这本书和使用 Dask 扩展 Python(O’Reilly)的早期提案进行审查。特别感谢 O’Reilly 编辑和制作团队,尤其是 Virginia Wilson 和 Gregory Hyman,帮助我们整理文章并不知疲倦地与我们合作,以尽量减少错误、错别字等。任何剩余的错误都是作者的责任,有时违背了评论者和编辑的建议。
作者 Holden
我还要感谢我的妻子和合作伙伴们,他们忍受了我长时间泡在浴缸里写作的时光。特别感谢 Timbit 守卫家园,通常让我有理由早点起床(尽管我常常觉得时间太早)。
作者 Boris
我还要感谢我的妻子玛丽娜,她忍受了我长时间的写作会议,有时候会忽视她几个小时,以及我在 IBM 的同事们,他们进行了许多富有成效的讨论,帮助我更好地理解 Ray 的力量。
第一章:什么是 Ray,它在哪里?
Ray 主要是一个用于快速和简单分布式计算的 Python 工具。Ray 由加州大学伯克利分校的RISELab创建。该实验室的早期版本创建了最初的软件,最终成为 Apache Spark。RISELab 的研究人员成立了 Anyscale 公司,继续开发和提供围绕 Ray 的产品和服务。
注
您还可以从 Java 使用 Ray。与许多 Python 应用程序一样,Ray 在底层使用大量的 C++ 和一些 Fortran。Ray 流处理还包含一些 Java 组件。
Ray 的目标是解决比其前身更广泛的问题,支持从 actors 到机器学习(ML)到数据并行性等各种可扩展编程模型。其远程函数和 actor 模型使其成为一个真正通用的开发环境,而不仅仅是大数据环境。
Ray 根据需要自动扩展计算资源,使您可以专注于代码而不是管理服务器。除了传统的水平扩展(例如增加更多机器),Ray 还可以安排任务以利用不同的机器规模和加速器,如图形处理单元(GPUs)。
自从引入亚马逊 Web 服务(AWS)Lambda 以来,对无服务器计算的兴趣激增。在这种云计算模型中,云提供商根据需求分配机器资源,并代表其客户管理服务器。Ray 通过以下功能为通用无服务器平台奠定了坚实基础:
-
Ray 自动隐藏服务器。根据应用程序的需求,Ray 自动调整服务器规模。
-
通过支持 actors,Ray 实现了不仅是状态无关的编程模型(对于大多数无服务器实现来说是典型的),还包括有状态的编程模型。
-
它允许您指定资源,包括执行您的无服务器函数所需的硬件加速器。
-
它支持任务之间的直接通信,因此不仅支持简单函数,还支持复杂的分布式应用程序。
Ray 提供了丰富的库,简化了能够充分利用 Ray 无服务器功能的应用程序的创建。通常情况下,您需要不同的工具来处理从数据处理到工作流管理的所有内容。通过使用单一工具处理应用程序的较大部分,您不仅简化了开发,还简化了运营管理。
在本章中,我们将探讨 Ray 在生态系统中的定位,并帮助您决定它是否适合您的项目。
为什么需要 Ray?
当我们的问题变得太大,无法在单一进程中处理时,通常需要像 Ray 这样的工具。根据问题的规模,这可能意味着从多核到多台计算机的扩展,Ray 支持这些都支持。如果您发现自己在思考如何处理下个月的用户增长、数据或复杂性时,希望您能看看 Ray。Ray 的存在是因为扩展软件很难,而且随着时间推移,这类问题往往变得更加复杂而不是更简单。
Ray 不仅可以扩展到多台计算机,还可以在您无需直接管理服务器的情况下进行扩展。计算机科学家 Leslie Lamport 曾说过,“分布式系统是一种在其中您甚至不知道存在的计算机的故障可能导致您自己的计算机无法使用的系统。”尽管这种故障仍然可能发生,但 Ray 能够自动从许多类型的故障中恢复。
Ray 可以在您的笔记本电脑上以及使用相同的 API 在大规模上运行。这为使用 Ray 提供了一个简单的起始选项,无需您去云端开始实验。一旦您对 API 和应用程序结构感到满意,您可以简单地将代码移至云端,以获得更好的可扩展性,而无需修改代码。这填补了分布式系统和单线程应用程序之间存在的需求。Ray 能够使用相同的抽象来管理多线程和 GPU。
Ray 可以运行在哪些地方?
Ray 可以部署在各种环境中,从您的笔记本电脑到云端,再到像 Kubernetes 或 Yarn 这样的集群管理器,甚至可以部署在藏在桌子底下的六台树莓派上。¹ 在本地模式下,开始使用 Ray 可以简单到执行 pip install 和调用 ray.init。现代化的 Ray 大部分将在没有上下文的情况下自动初始化一个上下文,允许您甚至跳过这一步。
ray up 命令是 Ray 的一部分,允许您创建集群并执行以下操作:
-
使用提供商的软件开发工具包(SDK)或访问物理机器(如果直接在物理机器上运行)来配置新的实例/机器(如果在云端或集群管理器上运行)
-
执行 shell 命令以设置具有所需选项的 Ray
-
运行任何自定义的用户定义设置命令(例如,设置环境变量和安装软件包)
-
初始化 Ray 集群
-
部署自动缩放器(autoscaler)(如有必要)
除了 ray up 外,如果在 Kubernetes 上运行,还可以使用 Ray Kubernetes 运算符。虽然 ray up 和 Kubernetes 运算符是创建 Ray 集群的首选方法,但如果您有一组现有的机器(物理或虚拟机器),您也可以手动设置 Ray 集群。
根据部署选项的不同,相同的 Ray 代码将以不同的速度运行。例如,当您需要特定的库或硬件来运行代码时,情况可能变得更加复杂。我们将在下一章中更详细地讨论在本地模式下运行 Ray,并且如果您希望进一步扩展,我们将在 附录 B 中涵盖云和资源管理器的部署。
使用 Ray 运行您的代码
Ray 不仅仅是一个要导入的库;它也是一个集群管理工具。除了导入库之外,您还需要连接到一个 Ray 集群。有三种选项可以将您的代码连接到 Ray 集群:
调用 ray.init 而不带任何参数
这启动了一个嵌入式的单节点 Ray 实例,可以立即供应用程序使用。
使用 Ray 客户端 ray.init("ray://*<head_node_host>*:10001")
默认情况下,每个 Ray 集群都会启动一个 Ray 客户端服务器在主节点上运行,可以接收远程客户端连接。然而,需要注意的是,当客户端位于远程位置时,一些直接从客户端运行的操作可能会因广域网(WAN)延迟而变慢。Ray 在主节点和客户端之间的网络故障上不具有容错性。
使用 Ray 命令行 API
您可以使用 ray submit 命令在集群上执行 Python 脚本。这将把指定的文件复制到主节点集群上,并使用给定的参数执行它。如果要传递参数,则您的代码应使用 Python 的 sys 模块,该模块通过 sys.argv 提供对任何命令行参数的访问。这样做可以消除使用 Ray 客户端时的潜在网络故障点。
它在生态系统中的位置是什么?
Ray 位于问题空间的一个独特交汇点。
Ray 解决的第一个问题是通过管理资源(服务器、线程或 GPU)扩展您的 Python 代码。Ray 的核心构建模块包括调度器、分布式数据存储和一个 actor 系统。Ray 使用的强大调度器通用到足以实现简单的工作流,同时处理传统的规模问题。Ray 的 actor 系统为您提供了一种处理分布式执行状态的简单方法。因此,Ray 能够作为反应式系统,其多个组件能够对其周围环境作出反应。
除了可扩展的构建模块之外,Ray 还具有更高级别的库,如 Serve、Datasets、Tune、RLlib、Train 和 Workflows,这些库存在于机器学习问题领域。它们的设计面向的是具有数据科学背景而不一定是分布式系统背景的人员。
总体来说,Ray 生态系统在 图 1-2 中展示。
图 1-2. Ray 生态系统
让我们来看看这些问题领域,看看 Ray 如何适应并与现有工具进行比较。以下列表,改编自 Ray 团队的“Ray 1.x 架构”文档,比较了 Ray 与几个相关的系统类别:
集群编排器
类似于Kubernetes、Slurm和 Yarn 的集群编排器安排容器。Ray 可以利用这些来分配集群节点。
并行化框架
与 Python 并行化框架(如multiprocessing或Celery)相比,Ray 提供了一个更通用、性能更高的 API。此外,Ray 的分布式对象支持跨并行执行器的数据共享。
数据处理框架
Ray 的低级 API 比现有的数据处理框架如Spark、Mars或Dask更灵活,更适合作为“分布式粘合”框架。虽然 Ray 没有对数据模式、关系表或流数据流有固有的理解,但它支持运行许多这些数据处理框架,例如Modin、Dask on Ray、Mars on Ray和Spark on Ray(RayDP)。
演员框架
与Erlang、Akka和Orleans等专门的演员框架不同,Ray 将演员框架直接集成到编程语言中。此外,Ray 的分布式对象支持跨演员的数据共享。
工作流程
大多数人谈论工作流程时,讨论的是 UI 或脚本驱动的低代码开发。尽管这种方法对非技术用户可能有用,但它经常给软件工程师带来更多痛苦而非价值。Ray 使用编程化的工作流程实现,类似于Cadence。这种实现结合了 Ray 动态任务图的灵活性和强大的耐久性保证。Ray 工作流程在任务启动时的开销不到一秒,并支持数十万步的工作流程。它还利用 Ray 对象存储在步骤之间传递分布式数据集。
HPC 系统
不同于 Ray 的是,大多数高性能计算(HPC)系统暴露了任务和演员 API,提供了更大的应用程序灵活性。此外,许多 HPC 实现提供了优化的集体通信原语。Ray 提供了一个集体通信库,实现了许多这些功能。
大数据 / 可扩展数据帧
Ray 为可扩展 DataFrame 提供了几个 API,这是大数据生态系统的基石。Ray 建立在 Apache Arrow 项目之上,提供了一个(有限的)分布式 DataFrame API,称为ray.data.Dataset。这主要用于最简单的转换和从云端或分布式存储中读取数据。此外,Ray 还通过 Dask on Ray 提供了更类似于 pandas 的体验,后者利用 Ray 上的 Dask 接口。
我们在第九章中详细介绍了可扩展的 DataFrame。
警告
除了之前提到的库外,你可能会在 Mars on Ray 或 Ray 的(已弃用的)内置 pandas 支持中找到参考资料。这些库不支持分布式模式,因此可能会限制你的可扩展性。这是一个快速发展的领域,未来需要密切关注。
机器学习
Ray 有多个 ML 库,大多数情况下,它们用于将 ML 的精彩部分委托给现有工具,如 PyTorch、scikit-learn 和 TensorFlow,同时利用 Ray 的分布式计算功能进行扩展。Ray Tune实现了使用 Ray 能力在分布式机器组中并行训练多个本地 Python 模型的超参数调优。Ray Train实现了使用 PyTorch 或 TensorFlow 进行分布式训练。Ray 的RLlib接口提供了带有核心算法的强化学习。
Ray 之所以在 ML 的纯数据并行系统中脱颖而出,部分原因在于其 Actor 模型,它允许更轻松地跟踪状态(包括参数)和工作进程间通信。你可以使用该模型实现自己的定制算法,这些算法不属于 Ray 核心的一部分。
我们在第十章中详细介绍了机器学习。
工作流调度
工作流调度是一个乍看起来可能非常简单的领域之一。一个工作流“只是”需要完成的工作的图表。然而,所有程序都可以被表达为“只是”需要完成的工作的图表。在 2.0 版本中,Ray 引入了一个工作流库,用于简化传统业务逻辑工作流和大规模(例如,ML 训练)工作流的表达。
Ray 在工作流调度中独具特色,因为它允许任务调度其他任务,而无需回调到中心节点。这样可以提供更大的灵活性和吞吐量。
如果你觉得 Ray 的工作流引擎过于低级,你可以使用 Ray 来运行 Apache Airflow。Airflow 是大数据领域中较受欢迎的工作流调度引擎之一。Ray 的 Apache Airflow 提供程序允许你将 Ray 集群用作 Airflow 的工作池。
我们在第八章中详细介绍了工作流调度。
流式处理
流处理通常被认为是处理“准实时”数据或“随到随用”数据。流处理增加了另一层复杂性,特别是在尝试接近实时处理时,因为并非所有数据都会按顺序或按时到达。Ray 提供标准的流处理基元,并可以使用 Kafka 作为流数据的来源和接收端。Ray 使用其 Actor 模型 API 与流数据进行交互。
Ray 流处理,像许多将流处理系统附加到批处理系统的系统一样,具有一些有趣的怪癖。值得注意的是,Ray 流处理主要在 Java 中实现其逻辑,与 Ray 的其他组件不同。这可能会使得调试流处理应用程序比 Ray 的其他组件更具挑战性。
我们将在第六章中讨论如何使用 Ray 构建流式应用程序。
交互式
并非所有“准实时”应用程序都必然是流式应用程序。一个常见的例子是交互式地探索数据集。类似地,与用户输入进行交互(例如,服务模型)可以被视为交互式而不是批处理过程,但它与 Ray Serve 的流处理库分开处理。
Ray 不是什么
尽管 Ray 是一个通用的分布式系统,但重要的是要注意 Ray 并非(至少在不付出大量努力的情况下):
-
结构化查询语言(SQL)或分析引擎
-
数据存储系统
-
适合运行核反应堆
-
完全独立于语言
Ray 可以用来执行所有这些操作,但您可能更适合使用更专业的工具。例如,虽然 Ray 有一个键/值存储,但它并非设计用于生存领导节点的丢失。这并不意味着如果您发现自己的问题需要一点 SQL 或一些非 Python 库,Ray 就不能满足您的需求——您可能只需引入额外的工具。
结论
Ray 有潜力极大地简化中到大规模问题的开发和运维负担。它通过提供统一的 API 解决传统上分离的各种问题,同时提供无服务器可扩展性来实现这一目标。如果您的问题跨越 Ray 服务的领域,或者您已经厌倦了管理自己集群的运维负担,我们希望您能加入我们,共同探索学习 Ray 的旅程。
在下一章中,我们将向您展示如何在本地模式下在您的计算机上安装 Ray。我们还将查看 Ray 支持的生态系统中一些 Hello World 示例。
¹ ARM 支持,包括树莓派,目前需要手动构建。
第二章:使用 Ray 入门(本地)
正如我们所讨论的,Ray 可以用来管理从单台计算机到集群的资源。开始使用本地安装更简单,可以利用多核/多 CPU 机器的并行性。即使在部署到集群时,您也会希望在本地安装 Ray 以进行开发。安装完 Ray 后,我们将向您展示如何创建和调用第一个异步并行化函数,并在 actor 中存储状态。
提示
如果您着急的话,也可以在书籍的 GitHub 仓库上使用 Gitpod 获取带有示例的 Web 环境,或者查看 Anyscale 的托管 Ray。
安装
即使在单台机器上,安装 Ray 的复杂程度从相对简单到非常复杂不等。Ray 将轮子发布到 Python 包索引(PyPI),遵循正常的发布周期和每夜版发布。目前这些轮子仅适用于 x86 用户,因此 ARM 用户大多需要从源代码构建 Ray。¹
提示
在 macOS 上的 M1 ARM 用户可以使用 Rosetta 上的 x86 包。会有一些性能下降,但设置起来简单得多。要使用 x86s 包,请安装 macOS 的 Anaconda。
x86 和 M1 ARM 的安装
大多数用户可以运行 pip install -U ray 从 PyPI 自动安装 Ray。当你需要在多台机器上分布计算时,在 Conda 环境中工作通常更容易,这样你可以匹配 Python 版本和集群的包依赖关系。示例 2-1 中的命令设置了一个带有 Python 的全新 Conda 环境,并且使用最少的依赖项安装了 Ray。
示例 2-1. 在 Conda 环境中安装 Ray
conda create -n ray python=3.7 mamba -y
conda activate ray
# In a Conda env this won't be auto-installed with Ray, so add them
pip install jinja2 python-dateutil cloudpickle packaging pygments \
psutil nbconvert ray
ARM 的安装(来自源代码)
对于 ARM 用户或任何没有预先构建轮子的系统架构的用户,您需要从源代码构建 Ray。在我们的 ARM Ubuntu 系统上,我们需要安装额外的软件包,如示例 2-2 所示。
示例 2-2. 从源代码安装 Ray
sudo apt-get install -y git tzdata bash libhdf5-dev curl pkg-config wget \
cmake build-essential zlib1g-dev zlib1g openssh-client gnupg unzip libunwind8 \
libunwind-dev openjdk-11-jdk git
# Depending on Debian version
sudo apt-get install -y libhdf5-100 || sudo apt-get install -y libhdf5-103
# Install bazelisk to install bazel (needed for Ray's CPP code)
# See https://github.com/bazelbuild/bazelisk/releases
# On Linux ARM
BAZEL=bazelisk-linux-arm64
# On Mac ARM
# BAZEL=bazelisk-darwin-arm64
wget -q https://github.com/bazelbuild/bazelisk/releases/download/v1.10.1/${BAZEL} \
-O /tmp/bazel
chmod a+x /tmp/bazel
sudo mv /tmp/bazel /usr/bin/bazel
# Install node, needed for the UI
curl -fsSL https://deb.nodesource.com/setup_16.x | sudo bash -
sudo apt-get install -y nodejs
如果您是不想使用 Rosetta 的 M1 Mac 用户,您需要安装一些依赖项。您可以使用 Homebrew 和 pip 安装它们,如示例 2-3 所示。
示例 2-3. 在 M1 上安装额外的依赖项
brew install bazelisk wget python@3.8 npm
# Make sure Homebrew Python is used before system Python
export PATH=$(brew --prefix)/opt/python@3.8/bin/:$PATH
echo "export PATH=$(brew --prefix)/opt/python@3.8/bin/:$PATH" >> ~/.zshrc
echo "export PATH=$(brew --prefix)/opt/python@3.8/bin/:$PATH" >> ~/.bashrc
# Install some libraries vendored incorrectly by Ray for ARM
pip3 install --user psutil cython colorama
您需要单独构建一些 Ray 组件,因为它们使用不同的语言编写。这确实使安装变得更加复杂,但您可以按照示例 2-4 中的步骤操作。
示例 2-4. 安装 Ray 的构建工具
git clone https://github.com/ray-project/ray.git
cd ray
# Build the Ray UI
pushd python/ray/new_dashboard/client; npm install && npm ci && npm run build; popd
# Specify a specific bazel version as newer ones sometimes break.
export USE_BAZEL_VERSION=4.2.1
cd python
# Mac ARM USERS ONLY: clean up the vendored files
rm -rf ./thirdparty_files
# Install in edit mode or build a wheel
pip install -e .
# python setup.py bdist_wheel
提示
构建中最慢的部分是编译 C++代码,即使在现代计算机上也可能需要一个小时。如果您有一台装有多个 ARM 处理器的集群,仅在集群上构建一次 wheel 并在集群上重用它通常是值得的。
Hello Worlds
现在您已经安装了 Ray,是时候了解一些 Ray API 了。稍后我们会更详细地介绍这些 API,所以现在不要太过于纠结于细节。
Ray 远程(任务/未来对象)Hello World
Ray 的核心构建块之一是远程函数,它们返回未来对象。这里的术语远程表示远程到我们的主进程,可以在同一台或不同的机器上。
要更好地理解这一点,您可以编写一个返回其运行位置的函数。Ray 将工作分布在多个进程之间,在分布式模式下,还可以在多台主机之间工作。这个函数的本地(非 Ray)版本显示在示例 2-5 中。
示例 2-5. 一个本地(常规)函数
def hi():
import os
import socket
return f"Running on {socket.gethostname()} in pid {os.getpid()}"
您可以使用ray.remote装饰器创建一个远程函数。调用远程函数与调用本地函数有所不同,需要在函数上调用.remote。当您调用远程函数时,Ray 将立即返回一个未来对象,而不是阻塞等待结果。您可以使用ray.get来获取这些未来对象返回的值。要将示例 2-5 转换为远程函数,您只需使用ray.remote装饰器,如示例 2-6 所示。
示例 2-6. 将上一个函数转换为远程函数
@ray.remote
def remote_hi():
import os
import socket
return f"Running on {socket.gethostname()} in pid {os.getpid()}"
future = remote_hi.remote()
ray.get(future)
当您运行这两个示例时,您会看到第一个在同一个进程中执行,而 Ray 将第二个调度到另一个进程中。当我们运行这两个示例时,分别得到Running on jupyter-holdenk in pid 33和Running on jupyter-holdenk in pid 173。
Sleepy task
通过创建一个故意缓慢的函数(在我们的例子中是slow_task),并让 Python 在常规函数调用和 Ray 远程调用中计算,您可以轻松(虽然是人为的)了解远程未来如何帮助。参见示例 2-7。
示例 2-7. 使用 Ray 并行化一个故意缓慢的函数
import timeit
def slow_task(x):
import time
time.sleep(2) # Do something sciency/business
return x
@ray.remote
def remote_task(x):
return slow_task(x)
things = range(10)
very_slow_result = map(slow_task, things)
slowish_result = map(lambda x: remote_task.remote(x), things)
slow_time = timeit.timeit(lambda: list(very_slow_result), number=1)
fast_time = timeit.timeit(lambda: list(ray.get(list(slowish_result))), number=1)
print(f"In sequence {slow_time}, in parallel {fast_time}")
当您运行此代码时,您会看到通过使用 Ray 远程函数,您的代码能够同时执行多个远程函数。虽然您可以使用multiprocessing在没有 Ray 的情况下做到这一点,但 Ray 会为您处理所有细节,并且还可以最终扩展到多台机器。
嵌套和链式任务
Ray 在分布式处理领域中非常显著,因为它允许嵌套和链式任务。在其他任务内部启动更多任务可以使某些类型的递归算法更容易实现。
使用嵌套任务的更直接的示例之一是网络爬虫。在网络爬虫中,我们访问的每个页面都可以启动对该页面上链接的多个额外访问,如示例 2-8 所示。
示例 2-8. 带有嵌套任务的网络爬虫
@ray.remote
def crawl(url, depth=0, maxdepth=1, maxlinks=4):
links = []
link_futures = []
import requests
from bs4 import BeautifulSoup
try:
f = requests.get(url)
links += [(url, f.text)]
if (depth > maxdepth):
return links # base case
soup = BeautifulSoup(f.text, 'html.parser')
c = 0
for link in soup.find_all('a'):
try:
c = c + 1
link_futures += [crawl.remote(link["href"], depth=(depth+1),
maxdepth=maxdepth)]
# Don't branch too much; we're still in local mode and the web is big
if c > maxlinks:
break
except:
pass
for r in ray.get(link_futures):
links += r
return links
except requests.exceptions.InvalidSchema:
return [] # Skip nonweb links
except requests.exceptions.MissingSchema:
return [] # Skip nonweb links
ray.get(crawl.remote("http://holdenkarau.com/"))
许多其他系统要求所有任务都在中央协调器节点上启动。即使支持以嵌套方式启动任务的系统,通常也仍然依赖于中央调度器。
数据 Hello World
Ray 在处理结构化数据时使用了稍微有限的数据集 API。Apache Arrow 驱动 Ray 的数据集 API。Arrow 是一种面向列的、语言无关的格式,具有一些流行的操作。许多流行的工具支持 Arrow,使它们之间的轻松转移成为可能(例如 Spark、Ray、Dask 和 TensorFlow)。
Ray 最近仅在版本 1.9 中添加了数据集上的键控聚合。最流行的分布式数据示例是词频统计,这需要聚合。我们可以执行令人尴尬的并行任务,如映射转换,构建一个网页数据集,如示例 2-9 所示。
示例 2-9. 构建一个网页数据集
# Create a dataset of URL objects. We could also load this from a text file
# with ray.data.read_text()
urls = ray.data.from_items([
"https://github.com/scalingpythonml/scalingpythonml",
"https://github.com/ray-project/ray"])
def fetch_page(url):
import requests
f = requests.get(url)
return f.text
pages = urls.map(fetch_page)
# Look at a page to make sure it worked
pages.take(1)
Ray 1.9 添加了GroupedDataset以支持各种类型的聚合。通过调用groupby,使用列名或返回键的函数,您可以获得GroupedDataset。GroupedDataset内置支持count、max、min和其他常见聚合。您可以使用GroupedDataset将示例 2-9 扩展为词频统计示例,如示例 2-10 所示。
示例 2-10. 将网页数据集转换为单词
words = pages.flat_map(lambda x: x.split(" ")).map(lambda w: (w, 1))
grouped_words = words.groupby(lambda wc: wc[0])
当你需要超越内置操作时,Ray 支持自定义聚合,只要你实现其接口。我们将更详细地讨论数据集,包括聚合函数,在第九章中。
注意
Ray 对其数据集 API 使用阻塞评估。当您在 Ray 数据集上调用函数时,它将等待直到完成结果,而不是返回一个 future。Ray 核心 API 的其余部分使用 futures。
如果您希望拥有功能齐全的 DataFrame API,可以将您的 Ray 数据集转换为 Dask。第九章详细介绍了如何使用 Dask 进行更复杂的操作。如果您想了解更多关于 Dask 的信息,请查看Scaling Python with Dask(O’Reilly),由 Holden 与 Mika Kimmins 共同撰写。
Actor Hello World
Ray 的一个独特之处在于它强调 Actor。Actor 为您提供管理执行状态的工具,这是扩展系统中较具挑战性的部分之一。Actor 发送和接收消息,根据响应更新其状态。这些消息可以来自其他 Actor、程序,或者您的主执行线程与 Ray 客户端。
对于每个演员,Ray 启动一个专用进程。每个演员都有一个等待处理的消息邮箱。当您调用一个演员时,Ray 将一条消息添加到相应的邮箱中,从而允许 Ray 序列化消息处理,从而避免昂贵的分布式锁。演员可以在响应消息时返回值,因此当您向演员发送消息时,Ray 立即返回一个未来对象,以便在演员处理完您的消息后获取该值。
Ray 演员的创建和调用方式与远程函数类似,但使用 Python 类来实现,这使得演员有一个存储状态的地方。您可以通过修改经典的“你好世界”示例来看到它的运行过程,按顺序向您问候,如示例 2-11 所示。
示例 2-11. 演员你好世界
@ray.remote
class HelloWorld(object):
def __init__(self):
self.value = 0
def greet(self):
self.value += 1
return f"Hi user #{self.value}"
# Make an instance of the actor
hello_actor = HelloWorld.remote()
# Call the actor
print(ray.get(hello_actor.greet.remote()))
print(ray.get(hello_actor.greet.remote()))
该示例相当基础;它缺乏任何容错性或每个演员内部的并发性。我们将在第四章中更深入地探讨这些内容。
结论
在本章中,您已经在本地机器上安装了 Ray,并使用了其许多核心 API。大多数情况下,您可以继续在本地模式下运行我们为本书选择的示例。当然,本地模式可能会限制您的规模或运行时间更长。
在接下来的章节中,我们将深入探讨 Ray 背后的一些核心概念。其中一个概念(容错性)更容易通过集群或云来进行说明。因此,如果您可以访问云账户或集群,现在是跳转到附录 B 并查看部署选项的绝佳时机。
¹ 随着 ARM 的普及,Ray 更有可能添加 ARM 版本的安装包,所以这只是暂时的情况。
² 演员仍然比无锁远程函数更昂贵,后者可以进行水平扩展。例如,许多工作进程调用同一个演员更新模型权重仍然比尴尬并行操作慢。
第三章:远程函数
构建现代大规模应用程序时,通常需要某种形式的分布式或并行计算。许多 Python 开发者通过 multiprocessing 模块 来了解并行计算。Multiprocessing 在处理现代应用程序需求方面存在局限性。这些需求包括:
-
在多个核心或机器上运行相同的代码
-
使用工具处理机器和处理故障
-
高效处理大参数
-
在进程之间轻松传递信息
与多进程不同,Ray 的远程函数满足这些要求。远程并不一定指的是一个独立的计算机,尽管名字如此;该函数可能在同一台机器上运行。Ray 提供的是将函数调用映射到正确进程的服务。在调用远程函数时,实际上是在多核或不同机器上异步运行,而无需关心如何或在哪里。
注意
异步是指在同一时间内同时运行多个任务,而无需等待彼此完成的一种花哨方式。
在本章中,您将学习如何创建远程函数,等待它们完成并获取结果。一旦掌握了基础知识,您将学会组合远程函数以创建更复杂的操作。在深入学习之前,让我们首先理解前一章中忽略的一些内容。
Ray 远程函数的基本要素
在 示例 2-7 中,您学会了如何创建基本的 Ray 远程函数。
当您调用远程函数时,它会立即返回一个 ObjectRef(即 future),这是对远程对象的引用。Ray 在后台创建并执行任务,并在完成时将结果写入原始引用中。然后,您可以调用 ray.get 来获取值。请注意,ray.get 是一个阻塞方法,等待任务执行完成后返回结果。
示例 2-7 中的一些细节值得理解。该示例在将迭代器传递给 ray.get 之前将其转换为列表。在调用 ray.get 时,需要这样做,以便传入一个 futures 列表或单个 future。¹ 该函数等待直到获取到所有对象,然后按顺序返回列表。
小贴士
就像普通的 Ray 远程函数一样,重要的是考虑每次远程调用内部执行的工作量。例如,使用 ray.remote 递归计算阶乘会比在本地执行慢,因为每个函数内部的工作量很小,即使整体工作量可能很大。确切的时间取决于您的集群繁忙程度,但一般规则是,如果没有特殊资源,几秒钟内执行的任何内容都不值得远程调度。
到目前为止,在我们的例子中,使用 ray.get 是可以接受的,因为所有的 future 具有相同的执行时间。如果执行时间不同,例如在不同大小的数据批次上训练模型时,并且您不需要同时获取所有结果,这样做可能会非常浪费。不要直接调用 ray.get,而是应该使用 ray.wait,它会返回已经完成的请求数量的 futures。要查看性能差异,您需要修改远程函数以具有可变的睡眠时间,就像 示例 3-1 中的示例一样。
示例 3-1. 具有不同执行时间的远程函数
@ray.remote
def remote_task(x):
time.sleep(x)
return x
正如您记得的那样,示例远程函数根据输入参数休眠。由于范围是按升序排列的,对其进行远程函数调用将导致按顺序完成的 futures。为了确保 futures 不会按顺序完成,您需要修改列表。一种方法是在映射远程函数到 things 之前调用 things.sort(reverse=True)。
要查看使用 ray.get 和 ray.wait 的差异,您可以编写一个函数,使用一些时间延迟收集 futures 的值,以模拟业务逻辑。
第一种选择,即不使用 ray.wait,在阅读上更简单和更清晰,如 示例 3-2 中所示,但不建议用于生产环境。
示例 3-2. ray.get 无需等待
# Process in order
def in_order():
# Make the futures
futures = list(map(lambda x: remote_task.remote(x), things))
values = ray.get(futures)
for v in values:
print(f" Completed {v}")
time.sleep(1) # Business logic goes here
第二种选择稍微复杂一些,如 示例 3-3 中所示。这通过调用 ray.wait 来找到下一个可用的 future 并迭代直到所有的 futures 完成。ray.wait 返回两个列表,一个是已完成任务的对象引用列表(请求的大小,默认为 1),另一个是剩余对象引用的列表。
示例 3-3. 使用 ray.wait
# Process as results become available
def as_available():
# Make the futures
futures = list(map(lambda x: remote_task.remote(x), things))
# While we still have pending futures
while len(futures) > 0:
ready_futures, rest_futures = ray.wait(futures)
print(f"Ready {len(ready_futures)} rest {len(rest_futures)}")
for id in ready_futures:
print(f'completed value {id}, result {ray.get(id)}')
time.sleep(1) # Business logic goes here
# We just need to wait on the ones that are not yet available
futures = rest_futures
将这些函数与 timeit.time 并行运行,您可以看到性能上的差异。需要注意的是,这种性能改进取决于非并行化业务逻辑(循环中的逻辑)所花费的时间。如果只是对结果求和,直接使用 ray.get 可能没问题,但如果执行更复杂的操作,则应该使用 ray.wait。当我们运行时,发现 ray.wait 大约快了两倍。您可以尝试变化睡眠时间并查看其效果。
您可能希望指定 ray.wait 的少数可选参数之一:
num_returns
在 Ray 返回之前等待完成的 ObjectRef 对象数量。您应该将 num_returns 设置为小于或等于 ObjectRef 对象输入列表的长度;否则,函数将抛出异常。² 默认值为 1。
timeout
在返回之前等待的最长时间(以秒为单位)。默认为 -1(表示无限)。
fetch_local
如果您只关心确保 futures 完成而不获取结果,则可以将其设置为 false 来禁用结果获取。
提示
在 ray.get 和 ray.wait 中,timeout 参数非常重要。如果未指定此参数并且您的某个远程函数表现不佳(永远不会完成),ray.get 或 ray.wait 将永远不会返回,并且您的程序将永远阻塞。³ 因此,对于任何生产代码,我们建议在这两者中使用 timeout 参数以避免死锁。
Ray 的 get 和 wait 函数在处理超时时略有不同。当 ray.wait 发生超时时,Ray 不会引发异常;而是简单地返回比 num_returns 更少的准备好的 futures。然而,如果 ray.get 遇到超时,Ray 将引发 GetTimeoutError。请注意,wait/get 函数的返回并不意味着您的远程函数将被终止;它仍将在专用进程中运行。如果要释放资源,可以显式终止未来(请参阅以下提示)。
提示
由于 ray.wait 可以以任意顺序返回结果,因此不依赖于结果顺序是非常重要的。如果需要对不同的记录进行不同的处理(例如,测试 A 组和 B 组的混合),则应在结果中进行编码(通常使用类型)。
如果某个任务在合理时间内未完成(例如,落后任务),可以使用与 wait/get 使用相同的 ObjectRef 使用 ray.cancel 取消该任务。您可以修改前面的 ray.wait 示例以添加超时并取消任何“坏”任务,从而得到类似 示例 3-4 的结果。
示例 3-4. 使用带有超时和取消的 ray.wait
futures = list(map(lambda x: remote_task.remote(x), [1, threading.TIMEOUT_MAX]))
# While we still have pending futures
while len(futures) > 0:
# In practice, 10 seconds is too short for most cases
ready_futures, rest_futures = ray.wait(futures, timeout=10, num_returns=1)
# If we get back anything less than num_returns
if len(ready_futures) < 1:
print(f"Timed out on {rest_futures}")
# Canceling is a good idea for long-running, unneeded tasks
ray.cancel(*rest_futures)
# You should break since you exceeded your timeout
break
for id in ready_futures:
print(f'completed value {id}, result {ray.get(id)}')
futures = rest_futures
警告
取消任务不应是您正常程序流的一部分。如果发现自己经常需要取消任务,应调查出现的原因。取消已取消任务的任何后续 wait 或 get 调用未指定,并可能引发异常或返回不正确的结果。
另一个在上一章中跳过的细微点是,虽然到目前为止的示例仅返回单个值,但 Ray 远程函数可以像常规 Python 函数一样返回多个值。
对于在分布式环境中运行的人来说,容错性是一个重要的考虑因素。假设执行任务的工作进程意外死亡(因为进程崩溃或者机器故障),Ray 将重新运行任务(有延迟),直到任务成功或者超过最大重试次数为止。我们在第五章中更详细地讨论了容错性。
远程 Ray 函数的组合
您可以通过组合远程函数使您的远程函数变得更加强大。Ray 中与远程函数组合最常见的两种方法是流水线化和嵌套并行处理。您可以使用嵌套并行处理来表达递归函数。Ray 还允许您表达顺序依赖关系,而无需阻塞或收集驱动程序中的结果,这被称为流水线化。
您可以通过使用来自前一个ray.remote的ObjectRef对象作为新远程函数调用的参数来构建流水线函数。Ray 将自动获取ObjectRef对象并将底层对象传递给您的函数。这种方法允许在函数调用之间轻松协调。此外,这种方法最小化了数据传输;结果将直接发送到执行第二个远程函数的节点。在示例 3-5 中展示了这种顺序计算的简单示例。
示例 3-5. Ray 管道化/顺序远程执行与任务依赖
@ray.remote
def generate_number(s: int, limit: int, sl: float) -> int :
random.seed(s)
time.sleep(sl)
return random.randint(0, limit)
@ray.remote
def sum_values(v1: int, v2: int, v3: int) -> int :
return v1+v2+v3
# Get result
print(ray.get(sum_values.remote(generate_number.remote(1, 10, .1),
generate_number.remote(5, 20, .2), generate_number.remote(7, 15, .3))))
此代码定义了两个远程函数,然后启动了第一个函数的三个实例。所有三个实例的ObjectRef对象随后被用作第二个函数的输入。在这种情况下,Ray 会等待所有三个实例完成后再开始执行sum_values。您不仅可以用这种方法传递数据,还可以表达基本的工作流风格的依赖关系。您可以传递的ObjectRef对象数量没有限制,同时还可以传递“普通”的 Python 对象。
您不能使用包含ObjectRef而不是直接使用ObjectRef的 Python 结构(例如列表、字典或类)。Ray 仅等待和解析直接传递给函数的ObjectRef对象。如果尝试传递结构,则必须在函数内部执行自己的ray.wait和ray.get。示例 3-6 是示例 3-5 的一个不起作用的变体。
示例 3-6. 断裂的顺序远程函数执行与任务依赖
@ray.remote
def generate_number(s: int, limit: int, sl: float) -> int :
random.seed(s)
time.sleep(sl)
return random.randint(0, limit)
@ray.remote
def sum_values(values: []) -> int :
return sum(values)
# Get result
print(ray.get(sum_values.remote([generate_number.remote(1, 10, .1),
generate_number.remote(5, 20, .2), generate_number.remote(7, 15, .3)])))
示例 3-6 已经从示例 3-5 修改为接受ObjectRef对象的列表作为参数,而不是ObjectRef对象本身。Ray 不会“查看”任何被传递的结构。因此,函数会立即被调用,由于类型不匹配,函数将失败并显示错误TypeError: unsupported operand type(s) for +: 'int' and 'ray._raylet.ObjectRef'。您可以通过使用ray.wait和ray.get来修复此错误,但这仍会过早地启动函数,导致不必要的阻塞。
在另一种组合方法嵌套并行性中,您的远程函数启动额外的远程函数。这在许多情况下都很有用,包括实现递归算法和将超参数调整与并行模型训练结合起来。⁴ 让我们看看两种实现嵌套并行性的方法(示例 3-7)。
示例 3-7. 实现嵌套并行性
@ray.remote
def generate_number(s: int, limit: int) -> int :
random.seed(s)
time.sleep(.1)
return randint(0, limit)
@ray.remote
def remote_objrefs():
results = []
for n in range(4):
results.append(generate_number.remote(n, 4*n))
return results
@ray.remote
def remote_values():
results = []
for n in range(4):
results.append(generate_number.remote(n, 4*n))
return ray.get(results)
print(ray.get(remote_values.remote()))
futures = ray.get(remote_objrefs.remote())
while len(futures) > 0:
ready_futures, rest_futures = ray.wait(futures, timeout=600, num_returns=1)
# If we get back anything less than num_returns, there was a timeout
if len(ready_futures) < 1:
ray.cancel(*rest_futures)
break
for id in ready_futures:
print(f'completed result {ray.get(id)}')
futures = rest_futures
这段代码定义了三个远程函数:
generate_numbers
一个生成随机数的简单函数
remote_objrefs
调用多个远程函数并返回生成的ObjectRef对象
remote_values
调用多个远程函数,等待它们完成并返回结果值
正如您从这个例子中可以看到的那样,嵌套并行性允许两种方法。在第一种情况(remote_objrefs)中,您将所有的ObjectRef对象返回给聚合函数的调用者。调用代码负责等待所有远程函数的完成并处理结果。在第二种情况(remote_values)中,聚合函数等待所有远程函数的执行完成,并返回实际的执行结果。
返回所有的ObjectRef对象可以更灵活地进行非顺序消耗,就像在ray.await中描述的那样,但对于许多递归算法来说并不适用。对于许多递归算法(例如快速排序、阶乘等),我们有许多级别的组合步骤需要执行,需要在每个递归级别上组合结果。
Ray 远程最佳实践
当您使用远程函数时,请记住不要将它们设计得太小。如果任务非常小,使用 Ray 可能比不使用 Ray 的 Python 花费更长的时间。其原因是每个任务调用都有非常复杂的开销,例如调度、数据传递、进程间通信(IPC)和更新系统状态。要从并行执行中获得真正的优势,您需要确保这些开销与函数本身的执行时间相比是可以忽略不计的。⁵
如本章所述,Ray remote 最强大的功能之一是能够并行执行函数。一旦你调用远程函数,远程对象(future)的句柄会立即返回,调用者可以继续在本地执行或者继续执行其他远程函数。如果此时调用 ray.get,你的代码将会阻塞,等待远程函数完成,结果就是你将失去并行性。为了确保你的代码并行化,你应该只在绝对需要数据继续主线程执行的时候调用 ray.get。此外,正如我们所描述的,推荐使用 ray.wait 而不是直接使用 ray.get。另外,如果一个远程函数的结果需要用于执行另一个远程函数(们),考虑使用流水线处理(前面描述过)来利用 Ray 的任务协调。
当你将参数提交给远程函数时,Ray 并不直接将它们提交给远程函数,而是将参数复制到对象存储中,然后将ObjectRef作为参数传递。因此,如果你将相同的参数发送给多个远程函数,你将为将相同的数据存储到对象存储中多次支付(性能)代价。数据的大小越大,惩罚越大。为了避免这种情况,如果你需要将相同的数据传递给多个远程函数,一个更好的选择是首先将共享数据放入对象存储中,然后使用生成的ObjectRef作为函数的参数。我们在“Ray 对象”中说明了如何做到这一点。
正如我们将在第五章中展示的那样,远程函数调用是由 Raylet 组件完成的。如果你从单个客户端调用了大量远程函数,所有这些调用都是由一个单独的 Raylet 完成的。因此,对于给定的 Raylet 来处理这些请求,需要一定的时间,这可能会延迟所有函数的启动。一个更好的方法,正如“Ray 设计模式”文档中所描述的,是使用调用树——即前面部分描述的嵌套函数调用。基本上,一个客户端创建了几个远程函数,每个远程函数依次创建更多的远程函数,依此类推。在这种方法中,调用被分布在多个 Raylet 之间,允许调度更快地进行。
每次你使用@ray.remote装饰器定义一个远程函数时,Ray 都会将这些定义导出到所有 Ray 工作节点,这需要一些时间(特别是如果你有很多节点)。为了减少函数导出的数量,一个好的实践是在顶层定义尽可能多的远程任务,避免在循环和本地函数中定义它们。
通过一个示例将其整合
由其他模型组成的 ML 模型(例如集成模型)非常适合使用 Ray 进行评估。示例 3-8 展示了使用 Ray 的函数组合来处理假设的网页链接垃圾邮件模型的样子。
示例 3-8. 集成模型
import random
@ray.remote
def fetch(url: str) -> Tuple[str, str]:
import urllib.request
with urllib.request.urlopen(url) as response:
return (url, response.read())
@ray.remote
def has_spam(site_text: Tuple[str, str]) -> bool:
# Open the list of spammers or download it
spammers_url = (
"https://raw.githubusercontent.com/matomo-org/" +
"referrer-spam-list/master/spammers.txt"
)
import urllib.request
with urllib.request.urlopen(spammers_url) as response:
spammers = response.readlines()
for spammer in spammers:
if spammer in site_text[1]:
return True
return False
@ray.remote
def fake_spam1(us: Tuple[str, str]) -> bool:
# You should do something fancy here with TF or even just NLTK
time.sleep(10)
if random.randrange(10) == 1:
return True
else:
return False
@ray.remote
def fake_spam2(us: Tuple[str, str]) -> bool:
# You should do something fancy here with TF or even just NLTK
time.sleep(5)
if random.randrange(10) > 4:
return True
else:
return False
@ray.remote
def combine_is_spam(us: Tuple[str, str], model1: bool, model2: bool, model3: bool) ->
Tuple[str, str, bool]:
# Questionable fake ensemble
score = model1 * 0.2 + model2 * 0.4 + model3 * 0.4
if score > 0.2:
return True
else:
return False
使用 Ray 而不是对所有模型的评估时间求和,您只需等待最慢的模型,而所有更快完成的模型则是“免费”的。例如,如果这些模型运行时间相等,那么在没有 Ray 的情况下串行评估这些模型将需要几乎三倍的时间。
结论
在本章中,您了解了一个基本的 Ray 特性——远程函数的调用及其在跨多个核心和机器上创建并行异步执行中的应用。您还学习了多种等待远程函数执行完成的方法,以及如何使用 ray.wait 避免代码中的死锁。
最后,您了解了远程函数组合及其如何用于基本执行控制(迷你工作流)。您还学会了实现嵌套并行处理,使您能够并行调用多个函数,而每个这些函数又可以依次调用更多并行函数。在下一章中,您将学习如何通过使用 actors 在 Ray 中管理状态。
¹ Ray 不会“深入”类或结构以解析 futures,因此如果您有一系列 futures 的列表或包含 future 的类,Ray 将不会解析“内部”future。
² 当传入的 ObjectRef 对象列表为空时,Ray 将其视为特殊情况,并立即返回,而不管 num_returns 的值如何。
³ 如果您正在交互式地工作,您可以通过 SIGINT 或 Jupyter 中的停止按钮来解决这个问题。
⁴ 然后,您可以并行训练多个模型,并使用数据并行梯度计算来训练每个模型,从而实现嵌套并行处理。
⁵ 作为练习,您可以从 示例 2-7 的函数中移除 sleep,您会发现使用 Ray 在远程函数执行上比常规函数调用花费的时间长几倍。开销并非恒定,而是取决于网络、调用参数的大小等因素。例如,如果您只有少量数据要传输,那么开销将比传输整个维基百科文本作为参数时要低。
第四章:远程演员
在前一章中,您了解了 Ray 远程函数,这对于并行执行无状态函数非常有用。但是如果您需要在调用之间维护状态怎么办?这些情况的例子包括从简单计数器到训练中的神经网络到模拟环境。
在这些情况下,维护状态的一种选项是将状态与结果一起返回并传递给下一次调用。尽管从技术上讲这是可行的,但由于需要传递的数据量较大(特别是在状态大小开始增长时),这并不是最佳解决方案。Ray 使用演员来管理状态,我们将在本章中介绍。
注意
就像 Ray 的远程函数一样,所有 Ray 演员都是远程演员,即使在同一台机器上运行时也是如此。
简而言之,演员是一个具有地址(句柄)的计算机进程。这意味着演员也可以将东西存储在内存中,私有于演员进程。在深入研究实现和扩展 Ray 演员的详细信息之前,让我们先看看它们背后的概念。演员来自演员模型设计模式。理解演员模型对于有效管理状态和并发至关重要。
理解演员模型
演员模型由 Carl Hewitt 在 1973 年引入,用于处理并发计算。这个概念模型的核心是演员,这是并发计算的通用原语,具有自己的状态。
一个演员的简单工作是:
-
存储数据
-
接收其他演员的消息
-
创建额外的子演员
演员存储的数据是私有的,并且在外部看不到;只有演员本身才能访问和修改它。改变演员的状态需要向将修改状态的演员发送消息。(与面向对象编程中使用方法调用相比较。)
为了确保演员的状态一致性,演员一次只处理一个请求。给定演员的所有演员方法调用都是全局串行化的。为了提高吞吐量,人们经常创建演员池(假设他们可以分片或复制演员的状态)。
演员模型非常适合许多分布式系统场景。以下是演员模型可以带来优势的一些典型用例:
-
你需要处理一个大型分布式状态,这在调用之间很难同步。
-
您希望使用不需要来自外部组件显著交互的单线程对象工作。
在这两种情况下,您将在一个演员内部实现工作的独立部分。您可以将每个独立状态的片段放入其自己的演员中,然后通过演员进行状态的任何更改。大多数演员系统实现通过仅使用单线程演员来避免并发问题。
现在您了解了演员模型的一般原则,让我们更详细地看看 Ray 的远程演员。
创建一个基本的 Ray 远程演员
Ray 将远程演员实现为有状态的工作者。创建新的远程演员时,Ray 会创建一个新的工作者,并在该工作者上安排演员的方法。
演员的一个常见示例是银行账户。让我们看看如何使用 Ray 远程演员来实现一个账户。通过使用 @ray.remote 装饰器简单地装饰一个 Python 类即可创建一个 Ray 远程演员(示例 4-1)。
示例 4-1. 实现一个 Ray 远程演员
@ray.remote
class Account:
def __init__(self, balance: float, minimal_balance: float):
self.minimal = minimal_balance
if balance < minimal_balance:
raise Exception("Starting balance is less than minimal balance")
self.balance = balance
def balance(self) -> float:
return self.balance
def deposit(self, amount: float) -> float:
if amount < 0:
raise Exception("Cannot deposit negative amount")
self.balance = self.balance + amount
return self.balance
def withdraw(self, amount: float) -> float:
if amount < 0:
raise Exception("Cannot withdraw negative amount")
balance = self.balance - amount
if balance < self.minimal:
raise Exception("Withdrawal is not supported by current balance")
self.balance = balance
return balance
Account 演员类本身非常简单,只有四种方法:
构造函数
基于起始和最低余额创建一个账户。它还确保当前余额大于最小值,并在否则情况下抛出异常。
balance
返回账户的当前余额。因为演员的状态对演员是私有的,因此只能通过演员的方法访问它。
deposit
存款到账户并返回新的余额。
withdraw
从账户中提取一定金额并返回新的余额。它还确保剩余余额大于预定义的最低余额,否则会抛出异常。
现在您已经定义了类,您需要使用 .remote 来创建此演员的实例(示例 4-2)。
示例 4-2. 创建您的 Ray 远程演员实例
account_actor = Account.remote(balance = 100.,minimal_balance=20.)
在这里,account_actor 表示一个演员句柄。这些句柄在演员生命周期中起着重要作用。在 Python 中,当初始演员句柄超出范围时,演员进程会自动终止(请注意,在这种情况下,演员的状态会丢失)。
提示
您可以从同一类创建多个不同的演员。每个演员将有自己独立的状态。
与 ObjectRef 一样,您可以将演员句柄作为参数传递给另一个演员或 Ray 远程函数或 Python 代码。
请注意,示例 4-1 使用 @ray.remote 注解将普通的 Python 类定义为 Ray 远程演员。或者,您可以使用 示例 4-3 将 Python 类转换为远程演员,而不是使用注解。
示例 4-3. 创建一个 Ray 远程演员实例而不使用装饰器
Account = ray.remote(Account)
account_actor = Account.remote(balance = 100.,minimal_balance=20.)
一旦您放置了一个远程演员,您可以通过使用 示例 4-4 调用它。
示例 4-4. 调用远程演员
print(f"Current balance {ray.get(account_actor.balance.remote())}")
print(f"New balance {ray.get(account_actor.withdraw.remote(40.))}")
print(f"New balance {ray.get(account_actor.deposit.remote(30.))}")
提示
处理异常很重要,在示例中,存款和取款方法的代码都可能出现异常。为了处理这些异常,您应该使用 try/except 语句扩展 示例 4-4:
try:
result = ray.get(account_actor.withdraw.remote(-40.))
except Exception as e:
print(f"Oops! \{e} occurred.")
这确保代码将拦截演员代码引发的所有异常并执行所有必要的操作。
您还可以通过使用 示例 4-5 创建具有命名的演员。
示例 4-5. 创建一个命名演员
account_actor = Account.options(name='Account')\
.remote(balance = 100.,minimal_balance=20.)
一旦 actor 有了名称,你就可以在代码的任何地方使用它来获取 actor 的句柄:
ray.get_actor('Account')
如前所述,默认 actor 的生命周期与 actor 的句柄处于作用域相关联。
actor 的生命周期可以与其句柄处于作用域无关联,允许 actor 在驱动程序进程退出后仍然存在。你可以通过指定生命周期参数为 detached 来创建一个脱离的 actor (示例 4-6)。
示例 4-6. 创建一个脱离的 actor
account_actor = Account.options(name='Account', lifetime='detached')\
.remote(balance = 100.,minimal_balance=20.)
理论上,你可以使一个 actor 脱离而不指定其名称,但由于 ray.get_actor 操作是按名称进行的,脱离的 actors 最好带有名称。你应该给你的脱离 actors 命名,这样你就可以在 actor 的句柄超出作用域后访问它们。脱离的 actor 本身可以拥有任何其他任务和对象。
另外,你可以在一个 actor 内部手动删除 actors,使用 ray.actor.exit_actor,或者通过一个 actor 的句柄 ray.kill(account_actor)。如果你知道不再需要特定的 actors 并且想要回收资源,这将会很有用。
正如这里所示,创建一个基本的 Ray actor 并管理其生命周期是相当容易的,但是如果运行 actor 的 Ray 节点由于某种原因宕机会发生什么呢?¹ @ray.remote 注解允许你指定两个 参数 来控制这种情况下的行为:
max_restarts
指定 actor 异常死亡时应重新启动的最大次数。最小有效值为 0(默认),表示 actor 不需要重新启动。值 -1 表示 actor 应该无限重新启动。
max_task_retries
指定 actor 任务因系统错误而失败时重试任务的次数。如果设置为 -1,系统将重试失败的任务,直到任务成功,或者 actor 达到其 max_restarts 限制为止。如果设置为 n > 0,系统将重试失败的任务多达 n 次,之后任务将在 ray.get 上抛出 RayActorError 异常。
正如在下一章和 Ray 容错文档 中进一步解释的那样,当一个 actor 被重新启动时,Ray 将通过重新运行其构造函数来重新创建其状态。因此,如果在 actor 执行过程中更改了状态,它将丢失。要保留这样的状态,actor 必须实现其自定义持久性。
在我们的示例案例中,由于我们没有使用 actor 持久性,actor 的状态在失败时会丢失。这对于某些用例可能是可以接受的,但对于其他用例来说是不可接受的—参见 Ray 设计模式文档。在下一节中,你将学习如何以编程方式实现自定义 actor 持久性。
实现 Actor 的持久性
在这个实现中,状态作为一个整体保存,如果状态的大小相对较小且状态更改相对较少,则这种方式足够好。此外,为了保持我们的示例简单,我们使用本地磁盘持久化。实际情况下,对于分布式 Ray 案例,您应考虑使用网络文件系统(NFS)、Amazon 简单存储服务(S3)或数据库来实现对演员数据的访问,使其能够从 Ray 集群中的任何节点访问。
在示例 4-7 中展示了一个持久化的 Account 演员。²
示例 4-7. 定义一个持久化演员,使用文件系统持久化
@ray.remote
class Account:
def __init__(self, balance: float, minimal_balance: float, account_key: str,
basedir: str = '.'):
self.basedir = basedir
self.key = account_key
if not self.restorestate():
if balance < minimal_balance:
raise Exception("Starting balance is less than minimal balance")
self.balance = balance
self.minimal = minimal_balance
self.storestate()
def balance(self) -> float:
return self.balance
def deposit(self, amount: float) -> float:
if amount < 0:
raise Exception("Cannot deposit negative amount")
self.balance = self.balance + amount
self.storestate()
return self.balance
def withdraw(self, amount: float) -> float:
if amount < 0:
raise Exception("Cannot withdraw negative amount")
balance = self.balance - amount
if balance < self.minimal:
raise Exception("Withdrawal is not supported by current balance")
self.balance = balance
self.storestate()
return balance
def restorestate(self) -> bool:
if exists(self.basedir + '/' + self.key):
with open(self.basedir + '/' + self.key, "rb") as f:
bytes = f.read()
state = ray.cloudpickle.loads(bytes)
self.balance = state['balance']
self.minimal = state['minimal']
return True
else:
return False
def storestate(self):
bytes = ray.cloudpickle.dumps(
{'balance' : self.balance, 'minimal' : self.minimal})
with open(self.basedir + '/' + self.key, "wb") as f:
f.write(bytes)
如果我们将此实现与示例 4-1 中的原始实现进行比较,我们将注意到几个重要的变化:
-
这里的构造函数有两个额外的参数:
account_key和basedir。账户密钥是账户的唯一标识符,也用作持久化文件的名称。basedir参数指示用于存储持久化文件的基本目录。当调用构造函数时,我们首先检查是否保存了此账户的持久状态,如果有,则忽略传入的余额和最低余额,并从持久状态中恢复它们。 -
在类中添加了两个额外的方法:
store_state和restore_state。store_state方法将演员状态存储到文件中。状态信息表示为一个字典,字典的键是状态元素的名称,值是状态元素的值。我们使用 Ray 的云串行化实现将此字典转换为字节字符串,然后将该字节字符串写入由账户密钥和基础目录定义的文件中。(第 5 章详细讨论了云串行化。)restore_state方法从由账户密钥和基础目录定义的文件中恢复状态。该方法从文件中读取一个二进制字符串,并使用 Ray 的云串行化实现将其转换为字典。然后,它使用字典的内容填充状态。 -
最后,
deposit和withdraw方法都会更改状态,使用store_state方法更新持久化。
在示例 4-7 中展示的实现工作正常,但我们的账户演员实现现在包含太多与持久化相关的代码,并且与文件持久化紧密耦合。一个更好的解决方案是将持久化特定的代码分离到一个单独的类中。
我们首先创建一个抽象类,定义了必须由任何持久化类实现的方法(示例 4-8)。
示例 4-8. 定义一个基础持久化类
class BasePersitence:
def exists(self, key:str) -> bool:
pass
def save(self, key: str, data: dict):
pass
def restore(self, key:str) -> dict:
pass
此类定义了必须由具体持久化实现实现的所有方法。有了这个基础,可以定义一个实现基础持久化的文件持久化类,如示例 4-9 所示。
示例 4-9. 定义文件持久化类
class FilePersistence(BasePersitence):
def __init__(self, basedir: str = '.'):
self.basedir = basedir
def exists(self, key:str) -> bool:
return exists(self.basedir + '/' + key)
def save(self, key: str, data: dict):
bytes = ray.cloudpickle.dumps(data)
with open(self.basedir + '/' + key, "wb") as f:
f.write(bytes)
def restore(self, key:str) -> dict:
if not self.exists(key):
return None
else:
with open(self.basedir + '/' + key, "rb") as f:
bytes = f.read()
return ray.cloudpickle.loads(bytes)
此实现从我们最初的实现中分离出大部分与持久化相关的代码,该实现位于示例 4-7 中。现在可以简化和概括账户实现;参见示例 4-10。
示例 4-10. 实现具有可插拔持久性的持久化 actor
@ray.remote
class Account:
def __init__(self, balance: float, minimal_balance: float, account_key: str,
persistence: BasePersitence):
self.persistence = persistence
self.key = account_key
if not self.restorestate():
if balance < minimal_balance:
raise Exception("Starting balance is less than minimal balance")
self.balance = balance
self.minimal = minimal_balance
self.storestate()
def balance(self) -> float:
return self.balance
def deposit(self, amount: float) -> float:
if amount < 0:
raise Exception("Cannot deposit negative amount")
self.balance = self.balance + amount
self.storestate()
return self.balance
def withdraw(self, amount: float) -> float:
if amount < 0:
raise Exception("Cannot withdraw negative amount")
balance = self.balance - amount
if balance < self.minimal:
raise Exception("Withdrawal is not supported by current balance")
self.balance = balance
self.storestate()
return balance
def restorestate(self) -> bool:
state = self.persistence.restore(self.key)
if state != None:
self.balance = state['balance']
self.minimal = state['minimal']
return True
else:
return False
def storestate(self):
self.persistence.save(self.key,
{'balance' : self.balance, 'minimal' : self.minimal})
从我们最初的持久化 actor 实现(示例 4-7)中,只展示了代码的变化。请注意,构造函数现在使用BasePersistence类,这使得可以轻松地更改持久化实现而不更改 actor 的代码。另外,restore_state和savestate方法被概括为将所有与持久化相关的代码移到持久化类中。
此实现足够灵活,支持不同的持久化实现,但是,如果持久化实现需要与持久化源(例如,数据库连接)建立永久连接,同时维护太多连接可能导致不可扩展。在这种情况下,我们可以将持久化实现为附加 actor。但这需要扩展此 actor。让我们看看 Ray 为扩展 actor 提供的选项。
缩放 Ray 远程 Actor
本章前面描述的原始 actor 模型通常假定 actor 是轻量级的(例如,包含单个状态片段)并且不需要扩展或并行化。在 Ray 和类似系统(包括 Akka)中,actor 通常用于更粗粒度的实现,并且可能需要扩展。³
与 Ray 远程函数一样,您可以使用池横向(跨进程/机器)或纵向(使用更多资源)扩展 actor。"资源/纵向扩展"介绍了如何请求更多资源,但现在,让我们专注于横向扩展。
您可以使用 Ray 的 actor 池为横向扩展添加更多进程,该池由ray.util模块提供。该类类似于多进程池,并允许您在一组固定的 actor 上安排任务。
actor 池有效地将一组固定的 actor 作为单个实体使用,并管理池中的下一个请求由哪个 actor 处理。请注意,池中的 actor 仍然是各自独立的 actor,并且它们的状态没有合并。因此,此缩放选项仅在 actor 的状态在构造函数中创建且在 actor 执行期间不更改时有效。
让我们看看如何通过在 Example 4-11 中添加一个 actor’s pool 来提高账户类的可扩展性。
示例 4-11. 使用 actor 的池实现持久性
pool = ActorPool([
FilePersistence.remote(), FilePersistence.remote(), FilePersistence.remote()])
@ray.remote
class Account:
def __init__(self, balance: float, minimal_balance: float,
account_key: str, persistence: ActorPool):
self.persistence = persistence
self.key = account_key
if not self.restorestate():
if balance < minimal_balance:
raise Exception("Starting balance is less than minimal balance")
self.balance = balance
self.minimal = minimal_balance
self.storestate()
def balance(self) -> float:
return self.balance
def deposit(self, amount: float) -> float:
if amount < 0:
raise Exception("Cannot deposit negative amount")
self.balance = self.balance + amount
self.storestate()
return self.balance
def withdraw(self, amount: float) -> float:
if amount < 0:
raise Exception("Cannot withdraw negative amount")
balance = self.balance - amount
if balance < self.minimal:
raise Exception("Withdrawal is not supported by current balance")
self.balance = balance
self.storestate()
return balance
def restorestate(self) -> bool:
while(self.persistence.has_next()):
self.persistence.get_next()
self.persistence.submit(lambda a, v: a.restore.remote(v), self.key)
state = self.persistence.get_next()
if state != None:
print(f'Restoring state {state}')
self.balance = state['balance']
self.minimal = state['minimal']
return True
else:
return False
def storestate(self):
self.persistence.submit(
lambda a, v: a.save.remote(v),
(self.key,
{'balance' : self.balance, 'minimal' : self.minimal}))
account_actor = Account.options(name='Account').remote(
balance=100.,minimal_balance=20.,
account_key='1234567', persistence=pool)
这里仅展示了我们原始实现的代码更改。代码从创建三个相同文件持久性 actor 的池开始,然后将此池传递给账户实现。
基于池的执行语法是一个接受两个参数的 lambda 函数:一个是 actor 引用,一个是要提交给函数的值。这里的限制是值是一个单一对象。对于具有多个参数的函数的解决方案之一是使用可以包含任意数量组件的元组。函数本身被定义为所需 actor 方法上的远程函数。
在池中执行是异步的(它将请求路由到一个远程的 actor)。这允许更快地执行 store_state 方法,它不需要来自数据存储的结果。在这里,实现不等待结果的状态存储完成;它只是开始执行。另一方面,restore_state 方法需要池调用的结果来进行后续操作。池的实现内部管理等待执行结果就绪的过程,并通过 get_next 函数公开这一功能(请注意,这是一个阻塞调用)。池的实现管理一个执行结果队列(按照请求的顺序)。因此,每当我们需要从池中获取结果时,我们必须首先清空池结果队列,以确保获取正确的结果。
除了由 actor 的池提供的基于多处理的扩展之外,Ray 还通过并发支持 actor 的执行扩展。Ray 在 actor 内部提供了两种并发类型:线程和异步执行。
在使用 actors 内部的并发时,请记住 Python 的 全局解释器锁(GIL) 一次只允许运行一个 Python 代码线程。纯 Python 将不提供真正的并行性。另一方面,如果调用 NumPy、Cython、TensorFlow 或 PyTorch 代码,这些库在调用 C/C++函数时会释放 GIL。通过重叠等待 I/O 时间或在本地库中工作,线程和异步 actor 执行都可以实现一定的并行性。
异步 io 库可以被看作是协同多任务:你的代码或库需要明确地信号表示正在等待结果,Python 可以继续执行另一个任务,通过明确切换执行上下文。asyncio 通过在事件循环中运行单一进程,并在任务 yield/await 时改变执行哪个任务来工作。与多线程执行相比,asyncio 的开销较低,且更容易理解。Ray actors 与 asyncio 集成,允许你编写异步 actor 方法,但不支持远程函数。
当你的代码大部分时间阻塞但不通过调用 await 放弃控制时,应使用线程执行。线程由操作系统决定何时运行哪个线程。使用线程执行可能涉及较少的代码更改,因为不需要明确指示代码何时放弃控制。这也可能使线程执行更难理解。
当使用线程和 asyncio 访问或修改对象时,需要小心并有选择地使用锁。在两种方法中,对象共享同一内存。通过使用锁,确保只有一个线程或任务可以访问特定内存。锁有一些开销(随着更多进程或线程等待锁而增加)。因此,actor 的并发性大多适用于在构造函数中填充状态并且不会更改状态的用例。
要创建使用 asyncio 的 actor,需要至少定义一个 async 方法。在这种情况下,Ray 将为执行 actor 的方法创建一个 asyncio 事件循环。从调用者的角度来看,提交任务到这些 actors 与提交任务到常规 actor 相同。唯一的区别是,当任务在 actor 上运行时,它被发布到后台线程或线程池中运行的 asyncio 事件循环,而不是直接在主线程上运行。(请注意,不允许在异步 actor 方法中使用阻塞的 ray.get 或 ray.wait 调用,因为它们会阻塞事件循环的执行。)
示例 4-12 展示了一个简单的异步 actor 示例。
示例 4-12. 创建一个简单的异步 actor
@ray.remote
class AsyncActor:
async def computation(self, num):
print(f'Actor waiting for {num} sec')
for x in range(num):
await asyncio.sleep(1)
print(f'Actor slept for {x+1} sec')
return num
因为方法 computation 被定义为 async,Ray 将创建一个异步 actor。请注意,与普通的 async 方法不同,后者需要使用 await 调用,使用 Ray 异步 actors 不需要任何特殊的调用语义。此外,Ray 允许在 actor 创建时指定异步 actor 执行的最大并发性:
actor = AsyncActor.options(max_concurrency=5).remote()
要创建一个线程 actor,需要在 actor 创建时指定 max_concurrency (示例 4-13)。
示例 4-13. 创建一个简单的线程 actor
@ray.remote
class ThreadedActor:
def computation(self, num):
print(f'Actor waiting for \{num} sec')
for x in range(num):
sleep(1)
print(f'Actor slept for \{x+1} sec')
return num
actor = ThreadedActor.options(max_concurrency=3).remote()
提示
由于异步和线程化 actor 都使用 max_concurrency,所以创建的 actor 类型可能会有些混淆。需要记住的是,如果使用了 max_concurrency,actor 可以是异步的或者线程化的。如果 actor 的至少一个方法是异步的,那么 actor 就是异步的;否则,它就是线程化的。
那么,我们应该使用哪种扩展方法来实现我们的应用程序?“Python 中的多进程 vs. 多线程 vs. AsyncIO” 由 Lei Mao 提供了各种方法特性的良好总结(参见 表 4-1)。
表 4-1. 比较 actor 的扩展方法
| 扩展方法 | 特性 | 使用条件 |
|---|---|---|
| Actor 池 | 多进程,高 CPU 利用率 | CPU 绑定 |
| 异步 actor | 单进程,单线程,协同多任务处理,任务协同决定切换 | 慢 I/O 绑定 |
| 线程化 actor | 单进程,多线程,抢占式多任务处理,由操作系统决定任务切换 |
快速 I/O 绑定和非异步库您无法控制
|
Ray 远程 actor 最佳实践
因为 Ray 远程 actor 实际上就是远程函数,因此前一章描述的所有 Ray 远程最佳实践同样适用。此外,Ray 还有一些特定于 actor 的最佳实践。
正如之前提到的,Ray 支持 actor 的容错。特别是对于 actor,您可以指定 max_restarts 来自动启用 Ray actor 的重启。当您的 actor 或托管该 actor 的节点崩溃时,actor 将自动重建。然而,这并不提供方法来恢复 actor 中的应用程序级状态。考虑到这一点,可以采用本章描述的 actor 持久化方法来确保执行级状态的恢复。
如果您的应用程序有全局变量需要更改,请不要在远程函数中更改它们。相反,使用 actor 封装它们,并通过 actor 的方法访问它们。这是因为远程函数在不同的进程中运行,并且不共享相同的地址空间。因此,这些更改不会在 Ray 驱动程序和远程函数之间反映出来。
一个常见的应用场景是为不同的数据集多次执行同一个远程函数。直接使用远程函数可能会因为创建新进程而导致延迟。这种方法也可能会因为大量进程而使 Ray 集群不堪重负。一个更为可控的选项是使用 actor 池。在这种情况下,池提供了一组受控的工作进程,这些工作进程可立即可用(无需进程创建延迟)进行执行。由于池在维护其请求队列,因此这种选项的编程模型与启动独立远程函数完全相同,但提供了更好的控制执行环境。
结论
在本章中,你学习了如何使用 Ray 远程 Actor 在 Ray 中实现有状态执行。你学习了 Actor 模型以及如何实现 Ray 远程 Actor。请注意,Ray 在内部大量依赖 Actor,例如用于多节点同步,流处理(参见第六章)以及微服务实现(参见第七章)。它还被广泛用于机器学习实现,例如用于实现参数服务器的 Actor。
你还学习了如何通过实现 Actor 的持久化来提高 Actor 的可靠性,并看到了一个简单的持久化实现示例。
最后,你了解了 Ray 提供的用于扩展 Actor、它们的实现以及权衡的选项。
在下一章中,我们将讨论更多 Ray 的设计细节。
¹ Python 异常并不被视为系统错误,不会触发重启。相反,异常会作为调用的结果保存,并且 Actor 将继续正常运行。
² 在这个实现中,我们使用文件系统持久化,但你也可以使用其他类型的持久化,比如 S3 或数据库。
³ 粗粒度 Actor 是一个单一 Actor,可能包含多个状态片段。相比之下,细粒度方法中,每个状态片段都将被表示为一个单独的 Actor。这类似于粗粒度锁定的概念。
第五章:光线设计细节
现在你已经创建并使用了远程函数和演员,是时候了解幕后发生了什么了。在本章中,你将了解重要的分布式系统概念,如容错性、Ray 的资源管理以及加速远程函数和演员的方法。当在分布式环境中使用 Ray 时,这些细节尤为重要,但即使是本地用户也会受益。对 Ray 工作原理的扎实理解将帮助你决定如何以及何时使用它。
容错性
容错性是指系统如何处理从用户代码到框架本身或其运行的机器的所有失败。Ray 为每个系统定制了不同的容错机制。与许多系统一样,Ray 无法从主节点故障中恢复。¹
警告
Ray 中存在一些不可恢复的错误,目前你无法配置它们。如果主节点、GCS 或应用与主节点之间的连接失败,你的应用程序将失败,并且无法被 Ray 恢复。如果你需要这些情况的容错性,你将不得不自行编写高可用性,很可能使用 ZooKeeper 或类似的低级工具。
总的来说,Ray 的 架构(见 图 5-1)包括一个应用层和一个系统层,两者都可以处理失败。
图 5-1。总体 Ray 架构
系统层由三个主要组件组成:一个 GCS,一个分布式调度器和一个分布式对象存储。除了 GCS 外,所有组件都是水平可扩展和容错的。
Ray 架构的核心是维护系统的整个控制状态的 GCS。在内部,GCS 是一个具有发布/订阅功能的键/值存储。² 目前,GCS 是一个单点故障,并且运行在主节点上。
使用集中维护 Ray 状态的 GCS 显着简化了整体架构,使得系统层的其他组件可以成为无状态的。这种设计对容错性(即在故障时,组件简单地重新启动并从 GCS 中读取血统)至关重要,并且使得可以独立扩展分布式对象存储和调度器变得容易,因为所有组件都通过 GCS 共享所需的状态。
由于远程函数不包含任何持久状态,因此从它们的故障中恢复相对简单。Ray 将尝试再次执行,直到成功或达到最大重试次数。正如前一章所示,你可以通过@ray.remote注解中的max_retries参数来控制重试次数。为了尝试并更好地理解 Ray 的容错性,请编写一个有一定百分比失败率的不稳定远程函数,如 示例 5-1 所示。
示例 5-1. 自动重试远程函数
@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)
如果您的不稳定函数失败,您将在标准错误输出看到WARNING worker.py:1215 -- A worker died or was killed while executing a task by an unexpected system error.的输出。当您执行ray.get时,您仍将得到正确的返回值,展示了 Ray 的容错能力。
提示
或者,为了查看容错机制的实际效果,如果您正在运行分布式 Ray 集群,您可以通过返回主机名并在运行请求时关闭节点来找到运行远程函数的节点。
对于远程 actor 而言,由于它们内部包含状态,因此容错是一个复杂的情况。这就是为什么在第四章中,您探讨了持久化和恢复该状态的选项。Actor 在任何阶段都可能经历故障:设置、消息处理或消息之间。
与远程函数不同,如果 actor 在处理消息时失败,Ray 不会自动重试。即使您设置了max_restarts,也是如此。Ray 将重新启动您的 actor 以处理下一个消息。发生错误时,您将收到一个RayActorError异常。
提示
Ray 的 actor 是惰性初始化的,因此在初始化阶段失败与在第一个消息上失败是相同的。
当 actor 在消息之间失败时,Ray 会自动尝试在下次调用时恢复 actor,最多重试max_retries次。如果您编写了良好的状态恢复代码,除了稍微慢一些的处理时间外,消息之间的故障通常是不可见的。如果没有状态恢复,每次重新启动都会将 actor 重置为初始值。
如果您的应用程序失败,几乎所有使用的资源最终都将被垃圾回收。唯一的例外是已分离资源,如已分离的 actor 或已分离的放置组。Ray 将根据配置继续重新启动这些资源,超出当前程序生命周期,只要集群不会失败。这可以防止您的集群缩减规模,因为 Ray 不会释放资源。
当首次存储后,Ray 不会自动尝试重新创建丢失的对象。当访问时,您可以配置 Ray 尝试重新创建丢失的对象。在下一节中,您将更多了解 Ray 对象以及如何配置这种弹性。
Ray 对象
Ray 对象可以包含任何可序列化的内容(在下一节中介绍),包括对其他 Ray 对象的引用,称为ObjectRef。ObjectRef本质上是一个唯一的 ID,指向远程对象,概念上类似于 futures。Ray 对象会自动为任务结果和 actor 及远程函数的大参数创建。您可以通过调用ray.put手动创建对象,它将返回一个立即准备就绪的ObjectRef,例如o = ray.put(1)。
提示
通常情况下,小对象最初存储在其所有者的进程存储中,而 Ray 将大对象存储在生成它们的工作节点上。这使得 Ray 能够平衡每个对象的内存占用和解析时间。
对象的所有者是创建初始 ObjectRef 的工作节点,通过提交创建任务或调用 ray.put 来管理对象的生命周期。所有者通过引用计数管理对象的生命周期。
提示
引用计数在定义对象时特别重要,当你完成使用对象时应将其设置为 None,或确保它们超出作用域。Ray 的引用计数容易受到循环引用的影响,即对象相互引用。通过运行 ray memory --group-by STACK_TRACE 打印存储在集群中的对象是查找 Ray 无法垃圾回收的对象的好方法。
Ray 对象是不可变的;它们不能被修改。需要注意的是,如果你改变了从 Ray 读取的对象(例如,使用 ray.get),或者存储在 Ray 中的对象(例如,使用 ray.put),这些改变不会反映在对象存储中。参见 示例 5-2。
示例 5-2. 不可变的 Ray 对象
remote_array = ray.put([1])
v = ray.get(remote_array)
v.append(2)
print(v)
print(ray.get(remote_array))
当你运行此代码时,你会发现虽然可以改变一个值,但这种改变不会传播到对象存储中。
如果一个参数或返回值很大并且被多次使用,或者中等大小但频繁使用,将其显式存储为对象可能是值得的。然后,你可以使用 ObjectRef 替换常规参数,Ray 将自动将 ObjectRef 转换为 Python 类型,如 示例 5-3 所示。
示例 5-3. 使用 ray.put
import numpy as np
@ray.remote
def sup(x):
import random
import sys
return len(x)
p = ray.put(np.array(range(0, 1000)))
ray.get([sup.remote(p), sup.remote(p), sup.remote(p)])
当另一个节点需要一个对象时,它会询问所有者是否有该对象的任何副本,然后获取并在本地创建该对象的副本。因此,同一对象的多个副本可以存在于不同节点的对象存储中。Ray 不会主动复制对象,因此也可能 Ray 只有一个对象的副本。
默认情况下,当尝试获取一个丢失的对象时,Ray 将引发 ObjectLostError。你可以通过向 ray.init 提供 enable_object_reconstruction=True 或在 ray start 中添加 --enable-object-reconstruction 来启用重构。这种重构仅在需要对象时(重构是按需解析的)使用 GCS 中的信息。
我们可以通过两种方式丢失一个对象。由于所有者负责引用计数,如果所有者丢失,对象也会丢失,无论是否存在对象的其他副本。如果没有对象的副本留下(例如,存储它的所有节点都死了),Ray 也会丢失对象。(这种情况是不同的,因为对象可能仅存储在与所有者不同的节点上。)
提示
Ray 在重构期间会遵循先前讨论的 max_retries 限制。
Ray 的对象存储使用引用计数垃圾回收来清理程序不再需要的对象。³ 对象存储跟踪直接和间接引用。⁴
即使有垃圾回收,对象存储也可能被对象填满。当对象存储填满时,Ray 会首先执行垃圾回收,删除没有引用的对象。如果内存压力仍然存在,对象存储将尝试溢出到磁盘。溢出到磁盘 将对象从内存复制到磁盘,称为spilling,因为它发生在内存使用溢出时。
注意
早期版本的 Ray 具有通过设置object_store_memory限制来按 actor 逐个删除对象的功能。
您可能希望微调对象存储设置。根据您的用例,您可能需要更多或更少的对象存储内存。您可以通过_system_config设置来配置对象存储。两个重要的配置选项包括溢出到磁盘的最小聚合大小min_spilling_size和分配给对象存储的总内存object_store_memory_mb。您可以在调用ray.init时设置这些选项,如示例 5-4 所示。
如果您有快慢不一的磁盘——例如固态驱动器(SSD)、硬盘驱动器(HDD)和网络——您应该考虑使用更快的存储来存储溢出的对象。与其余存储配置不同,您可以使用嵌套的 JavaScript Object Notation(JSON)块配置溢出对象存储位置。与对象存储设置的其他部分一样,object_spilling_config存储在_system_config下。这有点反直觉,但如果您的机器在*/tmp/fast*上有快速临时存储,您可以像示例 5-4 中那样配置 Ray 来使用它。
示例 5-4. Ray 对象存储配置
ray.init(num_cpus=20,
_system_config={
"min_spilling_size": 1024 * 1024, # Spill at least 1 MB
"object_store_memory_mb": 500,
"object_spilling_config": json.dumps(
{"type": "filesystem", "params": {"directory_path": "/tmp/fast"}},
)
})
类似 Ray 的框架使用序列化在 worker 之间传递数据和函数。在 Ray 能够将对象传输到对象存储之前,必须对对象进行序列化。
序列化/Pickling
Ray 及其类似系统依赖于序列化来存储和移动数据(和函数)在进程之间。不是所有对象都可序列化,因此不能在 worker 之间移动。除了对象存储和 IPC 之外,容错性依赖于序列化,因此相同的限制适用。
有许多种类的序列化,从多语言数据工具如 JSON 和 Arrow 到 Python 的内部 pickle。使用 pickle 进行序列化称为pickling。Pickling 可以处理比 JSON 更广泛的类型,但只能在 Python 进程之间使用。Pickling 并不适用于所有对象——在大多数情况下,没有好的方法来序列化(如网络连接),在其他情况下,是因为没有人有时间去实现它。
除了进程间通信外,Ray 还具有共享内存对象存储。该对象存储允许同一台计算机上的多个进程共享对象。
Ray 根据用例使用几种序列化技术。除了一些例外情况外,Ray 的 Python 库通常使用 cloudpickle 的分支,这是一种改进的 pickle。对于数据集,Ray 会尝试使用 Arrow,并在 Arrow 不适用时回退到 cloudpickle。Ray 的 Java 库使用各种序列化程序,包括 Fast Serialization 和 MessagePack。在内部,Ray 在工作节点之间使用 Google Protocol Buffers。作为 Ray Python 开发人员,您将从深入理解 cloudpickle 和 Arrow 序列化工具中获益最多。
cloudpickle
cloudpickle 工具序列化 Ray 中的函数、执行器和大部分数据。大多数非分布式 Python 代码不依赖于序列化函数。然而,集群计算通常需要序列化函数。cloudpickle 项目专为集群计算设计,可以序列化和反序列化比 Python 内置的 pickle 更多的函数。
提示
如果您不确定某些数据为何不可序列化,可以尝试查看堆栈跟踪或使用 Ray 函数 ray.util.inspect_serializability。
在对类进行 pickle 处理时,cloudpickle 仍然使用与 pickle 相同的扩展机制(getnewargs、getstate、setstate 等)。如果您的类具有非可序列化组件(如数据库连接),您可以编写自定义序列化程序。尽管这不允许您序列化诸如数据库连接之类的内容,但您可以序列化创建类似对象所需的信息。示例 5-5 采用此方法序列化包含线程池的类。
示例 5-5. 自定义序列化程序
import ray.cloudpickle as pickle
from multiprocessing import Pool
pickle
class BadClass:
def __init__(self, threadCount, friends):
self.friends = friends
self.p = Pool(threadCount) # not serializable
i = BadClass(5, ["boo", "boris"])
# This will fail with a "NotImplementedError: pool objects cannot be passed between
# processes or pickled"
# pickle.dumps(i)
class LessBadClass:
def __init__(self, threadCount, friends):
self.friends = friends
self.p = Pool(threadCount)
def __getstate__(self):
state_dict = self.__dict__.copy()
# We can't move the threads but we can move the info to make a pool
# of the same size
state_dict["p"] = len(self.p._pool)
return state_dict
def __setsate__(self):
self.__dict__.update(state)
self.p = Pool(self.p)
k = LessBadClass(5, ["boo", "boris"])
pickle.loads(pickle.dumps(k))
或者,Ray 允许您为类注册序列化程序。这种方法允许您更改不是您自己的类的序列化方式,如 示例 5-6 所示。
示例 5-6. 自定义序列化程序,外部类
def custom_serializer(bad):
return {"threads": len(bad.p._pool), "friends": bad.friends}
def custom_deserializer(params):
return BadClass(params["threads"], params["friends"])
# Register serializer and deserializer the BadClass:
ray.util.register_serializer(
BadClass, serializer=custom_serializer, deserializer=custom_deserializer)
ray.get(ray.put(i))
否则,您需要对类进行子类化和扩展,这在使用外部库时可能会使您的代码难以阅读。
注意
cloudpickle 要求加载和读取 Python 的版本完全相同。这一要求持续存在,这意味着所有 Ray 的工作节点必须具有相同的 Python 版本。
Apache Arrow
正如前面提到的,Ray 在可能时使用 Apache Arrow 序列化数据集。Ray 数据帧可以具有 Apache Arrow 不支持的类型。在底层,Ray 在加载数据到数据集时执行模式推断或转换。如果 Arrow 无法表示某一类型,则 Ray 将通过 cloudpickle 使用列表序列化数据集。
Arrow 可与许多数据处理和 ML 工具一起使用,包括 pandas、PySpark、TensorFlow 和 Dask。Arrow 是一种列式格式,具有强类型模式。它通常比 pickle 更节省空间,并且不仅可以在不同版本的 Python 之间使用,还可以在不同编程语言之间使用,例如 Rust、C、Java、Python 和 Compute Unified Device Architecture (CUDA)。
注意
并非所有使用 Arrow 的工具都支持相同的数据类型。例如,Arrow 支持嵌套列,而 pandas 不支持。
资源 / 垂直扩展
默认情况下,Ray 假设所有函数和 actors 具有相同的资源要求(例如,一个 CPU)。对于具有不同资源要求的 actors 或 functions,您可以指定所需的资源。调度程序将尝试找到具有这些可用资源的节点,如果没有,则下一个将尝试分配符合这些要求的节点的自动缩放程序将会运行。
ray.remote 装饰器将 num_cpus、num_gpus 和 memory 作为参数,用于指示 actor 或远程函数将消耗的资源量。默认值为一个 CPU 和零个 GPU。
提示
当未指定 CPU 要求时,远程函数和 actors 的资源分配行为不同。对于远程函数,分配和运行都需要一个 CPU。另外,对于 actors,如果未指定 CPU 资源,则 Ray 在调度时使用一个 CPU,运行时使用零个 CPU。这意味着该 actor 无法被调度到零 CPU 节点上,但可以在任何非零 CPU 节点上运行无限数量。另一方面,如果显式指定了资源,则分配和运行都需要这些资源。我们建议始终显式指定 CPU 资源要求,不要依赖默认值。
要覆盖默认资源值,请在 @ray.remote 注释中指定所需的资源。例如,使用注释 @ray.remote(num_cpus=4, num_gpus=2) 将请求四个 CPU 和两个 GPU 用于函数执行。
提示
Ray 中的大多数资源请求是 软 的,这意味着 Ray 不强制执行或保证限制,但会尽力尝试满足它们。
如果您知道任务或 actor 需要的内存量,可以在其 ray.remote 注释的资源要求中指定它以启用内存感知调度。⁵ 例如,@ray.remote(memory=500 * 1024 * 1024) 将为该任务请求 500 MiB 的内存。
Ray 还可以通过与内存和 CPU 资源相同的机制来跟踪和分配自定义资源。在工作器进程启动时,需要知道所有存在的资源。对于手动启动的工作器,您可以使用 --resources 参数指定自定义资源。例如,在混合架构集群上,您可能希望在 x86 节点上添加 --resources={"x86": "1"},在 ARM 节点上添加 --resources={"arm64":"1"}。参见 附录 B 了解如何使用部署机制配置资源。
提示
这些资源不必局限于硬件。如果由于许可问题某些节点上只有某些库或数据集可用,您可以使用相同的技术。
到目前为止,我们关注的是水平扩展,但您也可以使用 Ray 为每个进程获取更多资源。使用具有更多资源的机器进行扩展称为垂直扩展。您可以向 Ray 请求不同数量的内存、CPU 核心甚至 GPU 来执行任务和角色。默认的 Ray 配置仅支持相同大小的机器,但如 附录 B 所述,您可以创建多个节点类型。如果您创建了不同大小的节点或容器类型,可以用于垂直扩展。
自动缩放器
Ray 的一个重要组成部分是自动缩放器,负责管理工作器。更具体地说,自动缩放器负责以下三个功能:
根据需求启动新的工作器
包括上传用户定义的文件或目录,并在已启动的工作器上运行初始化/设置/启动命令。
终止工作节点
如果节点空闲、节点无法启动/初始化或节点配置发生更改,则会发生此情况。
重启工作器
如果 Raylet 运行的工作器崩溃或工作器的设置/启动/文件挂载发生变化,则会发生此情况。
自动缩放器会响应以下事件创建新节点:
使用 min-nodes 配置创建集群
在这种情况下,自动缩放器会创建所需数量的节点。
资源需求
对于具有资源需求的远程函数,自动缩放器会检查集群是否能够满足额外的资源需求,如果不能,则创建一个或多个新的工作节点。
放置组
类似于资源需求,对于新的放置组,自动缩放器会检查集群是否有足够的资源,如果不够,则创建新的工作节点。
这类似于集群创建请求,但这些资源在集群的整个生命周期中永不释放。
Ray 的自动缩放器可与不同类型的节点/计算机配合工作,这些节点可以映射到不同的物理实例类型(例如不同的 AWS 节点类型)或加速器(例如 GPU)。
若要了解有关自动扩展器的更多信息,请参考阿米尔·哈吉·阿里的视频 “瞥见 Ray 自动扩展器”。有关在不同平台上创建工作节点的更多信息,请参阅 Ray 的 云 VM 文档。
放置组:组织您的任务和执行器
Ray 应用程序使用放置组来组织任务以及预分配资源。有时为了重用资源和增加数据局部性,组织任务是很重要的。
提示
Ray 使用基于节点的数据存储,因此在同一节点上运行多个涉及大数据交换的函数会导致数据局部性,从而通常可以提高整体执行性能。
数据局部性可以减少需要传输的数据量,其基于这样的思想:序列化一个函数通常比序列化数据快得多。⁶ 另一方面,数据局部性还可以用来通过确保工作分布在许多计算机上来最小化硬件故障的影响。通过允许自动扩展器在需要之前请求多台机器来预分配资源,可以加快工作速度。
当您启动远程函数或执行器时,Ray 可能需要启动额外的节点来满足资源需求,这会延迟函数/执行器的创建。如果您尝试连续创建几个大函数/执行器,Ray 会按顺序创建工作节点,从而进一步减慢您的作业。您可以使用 Ray 的放置组来强制并行分配,通常可以减少资源的等待时间。
提示
Ray 会原子地创建放置组,因此如果您的任务运行之前需要最低数量的资源,您也可以使用放置组来实现此效果。请注意,放置组可能会经历部分重新启动。
您可以使用放置组来达到几个目的:
-
预分配资源
-
团体调度,以确保所有任务和执行器将在同一时间被调度并启动
-
在集群内部组织你的任务和执行器,以支持以下任一策略:
最大化数据局部性
确保将所有任务和执行器的放置尽可能靠近数据,以避免对象传输开销
负载平衡
通过尽可能将你的执行器或任务放置在不同的物理机器上来提高应用程序的可用性
放置组由每个工作节点的期望资源以及放置策略组成。
由于一个放置组可以跨多个工作节点,因此您必须为每个工作节点指定所需的资源(或资源捆绑包)。每个工作节点的资源组称为资源捆绑包,并且必须能够适应单台机器。否则,自动扩展器将无法创建节点类型,并且放置组将永远不会被调度。
放置组是资源包的集合,其中资源包是资源(CPU、GPU 等)的集合。您使用相同的参数定义资源包。每个资源包必须适合单台机器。
通过设置放置策略,您可以控制 Ray 调度资源组的方式。您的放置策略可以试图减少节点数量(提高局部性)或更加分散工作(提高可靠性和负载平衡)。您可以选择几种核心策略的变体:
STRICT_PACK
所有资源包必须放置在集群的单个节点上。
PACK
所有提供的资源包都尽可能打包到单个节点上。如果严格打包不可行,则资源包可以放置到其他节点上。这是默认的放置组策略。
STRICT_SPREAD
每个资源包必须安排在单独的节点上。
SPREAD
每个资源包将尽最大努力分散到不同的节点上。如果严格分散不可行,则某些资源包可以在节点上合并。
提示
多个远程函数或者角色可以位于同一个资源包中。使用同一个资源包的任何函数或角色将始终位于同一节点上。
放置组的生命周期具有以下阶段:
创建
放置组创建请求发送到 GCS,GCS 计算如何分发资源包,并发送资源预留请求给所有节点。Ray 保证放置组的原子性放置。
分配
放置组正在等待创建。如果现有的 Ray 节点能够满足给定策略的资源需求,则分配放置组并返回成功。否则,结果取决于 Ray 是否能够添加节点。如果自动扩展器不存在或节点限制已达到,则放置组分配失败并返回错误。否则,自动扩展器会扩展集群,以确保可以分配待处理组。
节点故障
当包含某个放置组某些资源包的工作节点失败时,GCS 将在不同节点上重新调度所有资源包。⁷ 放置组的原子性仅适用于初始放置创建。一旦放置组创建完成,由于节点故障可能变得部分。
清理
当创建放置组的作业完成时,Ray 会自动删除放置组。如果希望无论创建它的作业如何,保持放置组的活跃性,您应在放置组创建期间指定lifetime="detached"。您还可以随时通过调用remove_placement_group显式释放放置组。
要创建放置组,您需要一些额外的导入,如 示例 5-7 所示。如果在本地模式下使用 Ray,因为只有一个节点,很难看到放置组的效果。您仍然可以将仅 CPU 组合到一个放置组中。创建放置组后,您可以使用 options 在特定组中运行函数或执行器,如 示例 5-8 所示。
示例 5-7. 放置组导入
from ray.util.placement_group import (
placement_group,
placement_group_table,
remove_placement_group
)
示例 5-8. 仅 CPU 的放置组
# Create a placement group.
cpu_bundle = {"CPU": 3}
mini_cpu_bundle = {"CPU": 1}
pg = placement_group([cpu_bundle, mini_cpu_bundle])
ray.get(pg.ready())
print(placement_group_table(pg))
print(ray.available_resources())
# Run remote_fun in cpu_bundle
handle = remote_fun.options(placement_group=pg,
placement_group_bundle_index=0).remote(1)
如果在集群上运行 Ray,可以创建更复杂的资源组。如果集群中有一些 GPU 节点,则可以创建更复杂的放置组。当我们在测试集群上运行 示例 5-9 时,自动缩放器会分配一个带有 GPU 的节点。完成放置组后,您可以使用remove_placement_group(pg)来删除它。
示例 5-9. 混合 CPU 和 GPU 放置组
# Create a placement group.
cpu_bundle = {"CPU": 1}
gpu_bundle = {"GPU": 1}
pg = placement_group([cpu_bundle, gpu_bundle])
ray.get(pg.ready())
print(placement_group_table(pg))
print(ray.available_resources())
可以为放置组分配名称。您可以在创建放置组时通过指定参数name="desired_name"来实现这一点。这使您能够通过名称从 Ray 集群中的任何作业中检索并使用放置组,而无需传递放置组句柄。
命名空间
命名空间 是提供有限隔离的作业和执行器的逻辑分组。默认情况下,每个 Ray 程序都在自己的匿名命名空间中运行。匿名命名空间无法从另一个 Ray 程序中访问。要在您的 Ray 应用程序之间共享执行器,您需要将两个程序放在同一个命名空间中。在使用ray.init构建 Ray 上下文时,只需添加名为 namespace 的命名参数—例如,ray.init(namespace="timbit")。
注意
命名空间不旨在提供安全隔离。
您可以通过调用 ray.get_runtime_context().namespace 获取当前命名空间。
使用运行时环境管理依赖关系
Python 的一个重要优点是其丰富的工具生态系统。Ray 支持使用 Conda 和 Virtualenv 管理依赖关系。Ray 根据需要在更大的容器内动态创建这些虚拟环境,并使用匹配的环境启动工作进程。
将几个包添加到运行时上下文的最快方法是从 PyPI 指定所需包的列表。查看来自 第二章 的网络爬虫示例,您可以通过创建具有它的执行上下文来确保此包在分布式环境中可用,如 示例 5-10 所示。
示例 5-10. pip 包列表
runtime_env = {"pip": ["bs4"]}
这对少数依赖项非常有效,但是如果你有像 Holden 的 print-the-world 项目 中的 requirements.txt 文件,也可以直接指向你本地的 requirements.txt,就像 示例 5-11 中所示。
示例 5-11. pip 包需求文件
runtime_env = {"pip": "requirements.txt"}
提示
如果使用 Conda 进行更复杂的设置,可以通过使用 conda= 而不是 pip= 将 Conda 环境文件或包列表的路径传递给运行时上下文。
创建了运行时上下文后,可以在创建 Ray 客户端时全局指定它,例如 示例 5-12,或者在 ray.remote 装饰器内部指定,例如 示例 5-13。
示例 5-12. 使用整个程序的运行时环境
ray.init(num_cpus=20, runtime_env=runtime_env)
示例 5-13. 为特定函数使用运行时环境
@ray.remote(runtime_env=runtime_env)
def sup(x):
from bs4 import BeautifulSoup
警告
并非所有的依赖都适合动态创建的执行上下文。涉及大型原生代码编译且没有预先存在的 wheel 的任何内容都太耗时(例如 ARM 上的 TensorFlow)。
将某些包添加到运行时执行上下文可能导致启动和扩展速度较慢。例如,想想在没有 wheel 的情况下安装 TensorFlow 要花多长时间。如果 Ray 每次启动另一个工作节点都要做这件事,那速度会慢很多。你可以通过在集群或容器中创建 Conda 环境来解决这个问题。我们在 附录 B 中讨论了如何做到这一点。
使用 Ray 作业 API 部署 Ray 应用程序
除了通过 ray.init 将作业连接到现有集群外,Ray 还提供了作业 API。作业 API 提供了一种轻量级机制来提交作业,无需担心库不匹配的问题,也避免了远程集群与头节点之间不稳定网络的问题。你将使用作业 API 的三种主要方法来完成以下任务:
-
提交一个新的作业到集群,返回作业 ID
-
根据执行 ID 获取作业状态,返回提交作业的状态
-
根据执行 ID 获取作业的执行日志
作业请求包括以下内容:
-
一个包含文件和配置集合的目录,用于定义一个应用程序。
-
执行的入口点
-
由所需文件、Python 库和环境变量组成的运行时环境
示例 5-14 展示了如何使用作业 API 在 Ray 集群上运行你的代码。这是我们想要提交到集群的 Ray 代码。
示例 5-14. 作业提交
class ParseKwargs(argparse.Action):
def __call__(self, parser, namespace, values, option_string=None):
setattr(namespace, self.dest, dict())
for value in values:
key, value = value.split('=')
getattr(namespace, self.dest)[key] = value
parser = argparse.ArgumentParser()
parser.add_argument('-k', '--kwargs', nargs='*', action=ParseKwargs)
args = parser.parse_args()
numberOfIterations = int(args.kwargs["iterations"])
print(f"Requested number of iterations is: {numberOfIterations}")
print(f'Environment variable MY_VARIABLE has a value " +
f"of {os.getenv("MY_VARIABLE")}')
ray.init()
@ray.remote
class Counter:
def __init__(self):
self.counter = 0
def inc(self):
self.counter += 1
def get_counter(self):
return self.counter
counter = Counter.remote()
for _ in range(numberOfIterations):
ray.get(counter.inc.remote())
print(ray.get(counter.get_counter.remote()))
print("Requests", requests.__version__)
print("Qiskit", qiskit.__version__)
除了 Ray 代码本身,此示例还展示了其他几个内容:
-
获取在作业提交期间可用的变量
-
访问可以在作业提交期间设置的环境变量
-
获取在作业提交期间安装的库的版本
有了这些准备,您现在可以按以下方式将作业提交到 Ray 集群如下:
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}")
结论
通过本章,您深入了解了 Ray 的工作方式。您对序列化的了解将有助于理解哪些工作需要分发,哪些需要保留在同一进程中。现在您了解了选择正确扩展技术的选项。您还掌握了一些管理 Python 依赖项的技巧,甚至是冲突的依赖项,在 Ray 集群上。您已经准备好了解本书下一部分涵盖的更高级构建块。
¹ 一些分布式系统可以在头节点故障时继续运行;诸如 Apache ZooKeeper 和像 Paxos 或 Raft 这样的算法使用多台计算机监控并重新启动作业,采用投票系统。如果需要处理头节点故障,您可以编写自己的恢复逻辑,但这样做是复杂的。相反,像 Spark 这样具有集成作业重启功能的系统可能是更好的选择。
² 发布/订阅系统允许进程按类别订阅更新。
³ 此过程使用与 Python 相同的算法。
⁴ 这与 Python 的循环问题相同。
⁵ 指定内存需求并不对内存使用施加任何限制。这些需求仅用于调度时的准入控制(类似于 Ray 中的 CPU 调度方式)。任务本身应该避免超出请求的内存限制。
⁶ Ray 之前的系统,如 Apache Spark 和 Hadoop,利用数据本地性。
⁷ Ray 的头节点是单点故障,因此如果它失败,整个集群将失败,如“容错性”中所述。