Python-Ray-扩展指南-三-

680 阅读1小时+

Python Ray 扩展指南(三)

原文:annas-archive.org/md5/95872ff5b3ec96901f7e3cfb51cd271f

译者:飞龙

协议:CC BY-NC-SA 4.0

第九章:使用 Ray 进行高级数据处理

尽管数据生态系统的快速发展,您可能最终需要使用多种工具作为数据管道的一部分。Ray Datasets 允许在数据和 ML 生态系统中的工具之间共享数据。这使您可以在不复制或移动数据的情况下切换工具。Ray Datasets 支持 Spark、Modin、Dask 和 Mars,还可以与 TensorFlow 等 ML 工具一起使用。您还可以使用 Arrow 与 Ray 结合使用,使更多工具可以在数据集上运行,如 R 或 MATLAB。Ray Datasets 作为 ML 管道各个步骤的通用格式,简化了传统管道。

一切都归结为这一点:您可以在多个工具中使用相同的数据集,而不必担心细节。在内部,许多这些工具都有自己的格式,但 Ray 和 Arrow 会透明地进行转换管理。

除了简化您使用不同工具的过程之外,Ray 还拥有一个不断增长的内置操作集合,专为数据集设计。这些内置操作正在积极开发中,不打算与基于 Ray 构建的数据工具一样全面。

提示

如《光线对象》一文所述(“Ray Objects”),Ray Datasets 的默认行为可能与您的期望不同。您可以通过在 ray.init 中设置 enable_object_reconstruction=True 来启用对象恢复,使得 Ray Datasets 更加弹性化。

Ray Datasets 仍然是积极开发的一个领域,包括在次要版本之间的大型功能添加,到您阅读本章时可能会添加更多功能。尽管如此,分区和多工具互操作性的基本原则将保持不变。

创建和保存 Ray Datasets

正如您在 Example 2-9 中看到的,您可以通过调用 ray.data.from_items 从本地集合创建数据集。然而,本地集合自然地限制了您可以处理的数据范围,因此 Ray 支持许多其他选项。

Ray 使用 Arrow 将外部数据加载到数据集中,支持多种文件格式和文件系统。目前支持的格式包括 CSV、JSON、Parquet、NumPy、文本和原始二进制格式。加载数据的函数遵循 read_[*format*] 模式,并位于 ray.data 模块中,如 Example 9-1 所示。

Example 9-1. 加载本地数据
    ds = ray.data.read_csv(
        "2021",
        partition_filter=None # Since the file doesn't end in .csv
    )                   

在加载时,您可以指定目标 parallelism,但 Ray 可能会受到加载文件数量的限制。为您的目标并行度选择一个好的值是复杂的,并取决于多个因素。您希望确保您的数据可以轻松放入内存,并充分利用集群中的所有机器,同时又不要选择一个任务启动开销超过收益的数字。一般来说,导致分割在数百兆到数十吉字节之间的并行性常被认为是一个甜蜜点。

提示

如果您希望自定义 Arrow 加载数据的方式,可以通过 arrow_open_stream_args 参数向 Arrow 传递额外的参数,如 compressionbuffer_size

Arrow 在 S3、HDFS 和常规文件系统上具有内置的本机(快速)支持。Ray 会根据路径自动选择正确的内置文件系统驱动程序。

警告

当从本地文件系统加载时,在分布式模式下运行时,您需要确保文件在所有工作器上都可用。

Arrow,以及其扩展 Ray,还使用 fsspec,它支持更广泛的文件系统,包括 HTTPS(当安装 aiohttp 时)。与“内置”文件系统不同,您需要手动指定文件系统,如 示例 9-2 所示。

示例 9-2. 通过 HTTPS 加载数据
fs = fsspec.filesystem('https')
ds = ray.data.read_csv(
    "https://https://gender-pay-gap.service.gov.uk/viewing/download-data/2021",
    filesystem=fs,
    partition_filter=None # Since the file doesn't end in .csv
    )
警告

目前,协议被错误地剥离了,所以您需要将其重复两次。例如,当从 HTTPS 网站加载数据时,您应该从 https://https://[someurlhere].com 加载。

Ray 有能力以其可以从中读取的所有格式进行写入。写入函数与读取函数类似,遵循 write_[*format*] 模式。读取路径和写入路径之间存在一些细微差异。写入路径始终使用输入数据集的并行性,而不是接收 parallelism 参数:

word_count.write_csv("s3://ray-demo/wc")

如果 Ray 没有对您期望的格式或文件系统提供 I/O 支持,请检查 Ray 支持的其他工具中是否有适用的。然后,如下一节所述,您可以将数据集从/转换为所需的工具。

使用不同工具的 Ray 数据集

Ray 具有内置的工具,可在运行在 Ray 上的各种数据工具之间共享数据。这些工具中的大多数都有其自己的数据内部表示,但 Ray 会根据需要转换数据。在首次使用数据集与 Spark 或 Dask 之前,您需要运行一些设置代码,以便它们将其执行委托给 Ray,如示例 9-3 和 9-4 所示。

示例 9-3. 在 Ray 上设置 Dask
from ray.util.dask import enable_dask_on_ray, disable_dask_on_ray
enable_dask_on_ray() # Routes all Dask calls through the Ray scheduler
示例 9-4. 在 Spark 上设置 Dask
import raydp
spark = raydp.init_spark(
  app_name = "sleepy",
  num_executors = 2,
  executor_cores = 1,
  executor_memory = "2GB"
)

与用于读取和加载数据集的功能类似,将数据传输至 Ray 的功能在 ray.data 模块上定义,并遵循 from_[*x*] 模式,其中 [*x*] 是工具名称。与写入数据类似,我们使用数据集上定义的 to_[*x*] 函数将数据集转换为工具,其中 [*x*] 是工具名称。示例 9-5 展示了如何使用此模式将 Ray 数据集转换为 Dask DataFrame。

注意

数据集不使用 Ray 的运行时环境来处理依赖关系,因此您必须在工作器映像中安装所需的工具;请参阅 附录 B。对于 Spark 而言,这更加复杂,因为它需要 Java 虚拟机 (JVM) 和其他非 Python 组件。

示例 9-5. Dask 中的 Ray 数据集
dask_df = ds.to_dask()

您不仅限于 Ray 内置的工具。如果您有一个支持 Arrow 的新工具,并且正在使用 支持 Arrow 的类型to_arrow_refs 可以为您的数据集提供零拷贝的 Arrow 表示。然后,您可以使用 Ray Arrow 对象列表将其传递给您的工具,无论是用于模型训练还是其他任何目的。您将在 “使用内置 Ray 数据集操作” 中了解更多信息。

许多工具和语言可以与 Arrow 和 Ray 连接,包括:

提示

Dask 和 Spark 都有非 DataFrame 集合——bags、arrays 和 resilient distributed datasets (RDDs),这些无法使用这些 API 转换。

在 Ray 数据集上使用工具

本节假定您已经对将要在 Ray 上使用的数据整理工具——无论是 pandas 还是 Spark——有很好的理解。Pandas 对于扩展 Python 分析是理想的选择,而 Spark 则非常适合连接大数据工具的用户。如果您对 pandas API 不熟悉,建议查阅 Python 数据分析(Wes McKinney 著,O’Reilly 出版)。新的 Spark 用户应查阅 学习 Spark(Jules Damji 等著,O’Reilly 出版)。如果您想深入了解,Holden 推荐 高性能 Spark(Holden 和 Rachel Warren 著,O’Reilly 出版)。¹

使用 Dask 的类似 pandas 的 DataFrame

Ray 上的 Dask 是为 ML 数据准备或扩展现有 pandas 代码的极佳选择。许多最初的 Dask 开发者也参与了 pandas 的开发,因此具有相对稳固的分布式 pandas 接口。

注意

本节部分内容基于 Scaling Python with Dask 中的 DataFrame 章节。

在 Ray 上使用 Dask 可以从 Ray 的每个节点/容器共享内存存储数据中获益。在执行像广播连接这样的操作时尤为重要;在 Dask 中,相同的数据将需要存储在每个工作进程中。² 然而,在 Ray 中,只需要在每个节点或容器中存储一次。

警告

与 Ray 不同,Dask 通常是惰性的,这意味着它在被迫之前不会评估数据。这可能会增加调试的难度,因为错误可能出现在距离其根本原因几行的地方。

Dask 的 DataFrame 的大多数分布式组件使用三个核心构建块 map_partitionsreductionrolling。你通常不需要直接调用这些函数;而是会使用更高级别的 API,但理解它们以及它们的工作原理对于理解 Dask 的工作方式至关重要。shuffle 是重新组织数据的分布式 DataFrame 的关键构建块。与其他构建块不同,你可能更频繁地直接使用它,因为 Dask 无法抽象分区。

索引

在 pandas 中,对 DataFrame 进行索引是其强大功能之一,但在进入像 Dask 这样的分布式系统时,会有一些限制。由于 Dask 默认不跟踪每个分区的大小,因此不支持按行进行位置索引。你可以使用对列的位置索引,以及对列或行的标签索引。

索引经常用于过滤数据,仅保留你需要的组件。我们通过查看只包括所有疫苗接种状态的病例率的方式,在 San Francisco COVID-19 数据中执行了这项工作,如 示例 9-6 所示。

示例 9-6. Dask DataFrame 索引
mini_sf_covid_df = sf_covid_df[ sf_covid_df['vaccination_status'] == 
  'All'][['specimen_collection_date', 'new_cases']]

如果你确实需要按行进行位置索引,可以通过计算每个分区的大小并使用它来选择所需的分区子集来实现自己的方法。这种方法非常低效,因此 Dask 避免直接实现,所以在执行之前你需要做出有意识的选择。

Shuffles

如前一章节所述,shuffles 是昂贵的。造成 shuffle 昂贵的主要原因是网络速度相对于从内存读取数据而言较慢,以及序列化开销。随着被洗牌的数据量增加,这些成本会按比例增加,因此 Dask 有技术手段来减少被洗牌的数据量。这些技术依赖于某些数据属性或正在执行的操作。

注意

虽然理解 shuffle 对性能很重要,但如果你的代码运行良好,可以跳过本节。

滚动窗口和 map_overlap

触发需要 shuffle 的一种情况是滚动窗口,在分区的边缘,你的函数需要一些来自其邻居的记录。Dask DataFrame 具有一个特殊的 map_overlap 函数,你可以在其中指定一个向后查看窗口(也称为向前查看窗口)和一个向前查看窗口(也称为向后查看窗口)的行来传输(可以是整数或时间增量)。利用这一点的最简单示例是滚动平均,如 示例 9-7 所示。

示例 9-7. Dask DataFrame 滚动平均
def process_overlapped(df):
     df.rolling('5D').mean()
rolling_avg = partitioned_df.map_overlap(process_overlapped, pd.Timedelta('5D'), 0)

使用 map_overlap 允许 Dask 仅传输所需的数据。为了确保该实现能够正确运行,你的最小分区大小需要大于最大的窗口大小。

警告

Dask 的滚动窗口不会跨越多个分区。如果你的 DataFrame 分区方式使得向后或向前查看的长度大于相邻分区的长度,结果将会失败或者不正确。Dask 对于时间增量的向后查看进行了验证,但对于向前查看或整数增量则没有进行此类检查。

聚合

聚合是另一种可以减少需要通过网络传输的数据量的特殊情况。聚合是结合记录的函数。如果你来自于 map/reduce 或 Spark 的背景,reduceByKey 是经典的聚合方法。聚合可以是按键进行的,也可以是整个 DataFrame 的全局聚合。

要按键进行聚合,首先需要使用表示键的列(或用于聚合的键函数)调用 groupby。例如,调用 df.groupby("PostCode") 将根据邮政编码对 DataFrame 进行分组,或者调用 df.groupby(["PostCode", "SicCodes"]) 使用多列组合进行分组。在函数上,许多与 pandas 相同的聚合函数是可用的,但在 Dask 中聚合的性能与本地 pandas DataFrame 有很大不同。

提示

如果按分区键进行聚合,Dask 可以在不需要洗牌的情况下计算聚合结果。

加速聚合的第一种方法是减少进行聚合的列,因为处理速度最快的数据是没有数据。最后,如果可能,同时执行多个聚合可以减少多次洗牌相同数据的次数。因此,如果需要计算平均值和最大值,应该像 示例 9-8 中显示的那样同时计算两者。

示例 9-8. 同时计算 Dask DataFrame 的最大值和平均值
dask.compute(
    raw_grouped[["new_cases"]].max(),
    raw_grouped[["new_cases"]].mean())

对于像 Dask 这样的分布式系统,如果可以部分评估然后合并聚合,你可以在预洗牌前合并一些记录。并非所有部分聚合都是相等的。部分聚合重要的是当合并具有相同键的值时减少的数据量,与原始多个值使用的存储空间相比。

最有效的聚合方式可以在不考虑记录数量的情况下占用亚线性的空间量。其中一些可以占用恒定空间,如 sum、count、first、minimum、maximum、mean 和 standard deviation。更复杂的任务,如分位数和不同计数,也有亚线性近似选项。这些近似选项非常有效,因为精确答案可能需要线性增长的存储空间。

一些聚合函数的增长不是亚线性的,但“倾向于”或“可能”增长不会太快。计算不同值的数量属于此类别,但如果所有值都是唯一的,则没有节省空间。

要利用高效的聚合功能,你需要使用 Dask 提供的内置聚合,或者使用 Dask 的聚合类自己编写。在可能的情况下,使用内置聚合。内置聚合不仅需要更少的工作量,而且通常更快。并非所有的 pandas 聚合在 Dask 中都直接支持,因此有时你的唯一选择是编写自己的聚合。

如果选择编写自己的聚合,你需要定义三个函数:chunk 处理每个组/分区块,agg 组合分区之间 chunk 的结果,以及(可选的)finalize 用于获取 agg 的结果并生成最终值。

理解如何使用部分聚合的最快方法是查看一个使用所有三个函数的示例。在 Example 9-9 中使用加权平均值可以帮助你思考每个函数所需的内容。第一个函数需要计算加权值和权重。agg 函数通过对元组的各部分求和来组合这些值。最后,finalize 函数将总和除以权重。

Example 9-9. Dask 自定义聚合
# Write a custom weighted mean, we get either a DataFrameGroupBy with
# multiple columns or SeriesGroupBy for each chunk
def process_chunk(chunk):
    def weighted_func(df):
        return (df["EmployerSize"] * df["DiffMeanHourlyPercent"]).sum()
    return (chunk.apply(weighted_func), chunk.sum()["EmployerSize"])

def agg(total, weights):
    return (total.sum(), weights.sum())

def finalize(total, weights):
    return total / weights

weighted_mean = dd.Aggregation(
    name='weighted_mean',
    chunk=process_chunk,
    agg=agg,
    finalize=finalize)

aggregated = df_diff_with_emp_size.groupby("PostCode")
    ["EmployerSize", "DiffMeanHourlyPercent"].agg(weighted_mean)

在某些情况下,比如纯求和,你不需要在 agg 的输出上进行任何后处理,因此可以跳过 finalize 函数。

并非所有的聚合都必须按键进行;你还可以跨所有行计算聚合。然而,Dask 的自定义聚合接口仅在按键操作中暴露。

Dask 的内置完整 DataFrame 聚合使用一个称为 apply_contact_apply 的低级接口,用于部分聚合。与学习两种不同的部分聚合 API 相比,我们更倾向于通过提供一个常量分组函数来进行静态 groupby。这样,我们只需了解一个聚合接口。你可以使用这个方法在整个 DataFrame 中找到聚合 COVID-19 数字的方式,如 Example 9-10 所示。

Example 9-10. 聚合整个 DataFrame
raw_grouped = sf_covid_df.groupby(lambda x: 0)

如果存在内置的聚合方法,它们很可能比我们能写的任何东西都要好。有时部分聚合是部分实现的,就像 Dask 的 HyperLogLog 一样:它仅适用于完整的 DataFrames。你可以通过复制 chunk 函数,使用 aggcombine 参数,以及使用 finalizeaggregate 参数来转换简单的聚合。这在将 Dask 的 HyperLogLog 实现移植到 Example 9-11 中有所展示。

Example 9-11. 将 Dask 的 HyperLogLog 封装在 dd.Aggregation
# Wrap Dask's hyperloglog in dd.Aggregation

from dask.dataframe import hyperloglog

approx_unique = dd.Aggregation(
    name='aprox_unique',
    chunk=hyperloglog.compute_hll_array,
    agg=hyperloglog.reduce_state,
    finalize=hyperloglog.estimate_count)

aggregated = df_diff_with_emp_size.groupby("PostCode")
    ["EmployerSize", "DiffMeanHourlyPercent"].agg(weighted_mean)

慢/低效的聚合操作(或者可能导致内存不足异常的操作)会使用与正在聚合的记录成比例的存储空间。这些慢操作的例子包括制作列表和简单地计算精确分位数。³ 对于这些慢聚合操作,使用 Dask 的聚合类并不能比apply API 带来好处,后者可能更简单。例如,如果你只想通过邮政编码获取雇主 ID 列表,而不想编写三个函数,你可以使用像df.groupby("PostCode")["EmployerId"].apply(lambda g: list(g))这样的一行代码。Dask 将apply函数实现为完全的洗牌操作,这将在下一节中讨论。

警告

当你使用apply函数时,Dask 无法应用部分聚合。

完全洗牌和分区化

在分布式系统中,排序通常是昂贵的,因为它通常需要进行完全洗牌。有时,完全洗牌是使用 Dask 时不可避免的部分。有趣的是,虽然完全洗牌本身是慢的,但你可以用它来加速未来在相同分组键上进行的操作。正如在聚合部分提到的,通过在分区不对齐时使用apply方法,就会触发完全洗牌的一种方式。

分区

在重新分区数据时,你最常使用完全洗牌。在处理聚合、滚动窗口或查找/索引时,拥有正确的分区是很重要的。如“滚动窗口和 map_overlap”章节中讨论的,Dask 不能做超过一个分区的前视或后视,因此需要正确的分区才能获得正确的结果。对于大多数其他操作来说,分区不正确会减慢作业速度。

Dask 有三种主要方法来控制 DataFrame 的分区:set_indexrepartitionshuffle。当分区正在更改为新的键/索引时,使用set_indexrepartition保持相同的键/索引,但更改分片。repartitionset_index使用类似的参数,但repartition不接受索引键名。shuffle有些不同,因为它不生成“已知”的分区方案,像groupby这样的操作无法利用它。

获取 DataFrame 的正确分区的第一步是决定是否需要索引。索引对于几乎任何按键类型的操作都很有用,包括数据过滤、分组和当然索引。一个这样的按键操作可能是groupby;正在分组的列可能是一个很好的键的候选。如果在某列上使用滚动窗口,则该列必须是键,这使得选择键相对容易。一旦确定了索引,可以使用索引列名调用set_index(例如,set_index("PostCode"))。在大多数情况下,这将导致重新分区,因此现在是调整分区大小的好时机。

提示

如果您不确定当前用于分区的键,可以检查index属性来查看分区键。

一旦选择了键,下一个问题是如何确定分区的大小。在“分区”中的建议通常适用于这里:努力使每台计算机保持忙碌,但要记住一般的最佳区间为 100 MB 到 1 GB。如果给定目标分区数,Dask 通常会计算出相当均匀的分割。⁴ 幸运的是,set_index也会接受npartitions。要通过邮政编码重新分区数据,并设定为 10 个分区,可以添加set_index("PostCode", npartitions=10);否则,Dask 将默认使用输入分区的数量。

如果您计划使用滚动窗口,可能需要确保每个分区中涵盖了正确大小的(按键范围)记录。为了在set_index的一部分中执行此操作,您需要计算自己的分区,以确保每个分区中包含正确范围的记录。分区被指定为一个列表,从第一个分区的最小值到最后一个分区的最大值。每个值之间是用于分割构成 Dask DataFrame 的 pandas DataFrame 的“切点”。要创建一个包含[0, 100) [100, 200), (300, 500]的 DataFrame,您可以编写df.set_index("Num​Employees", divisions=[0, 100, 200, 300, 500])。类似地,为了支持从大流行开始到本文撰写时长达七天的滚动窗口,日期范围如示例 9-12 所示,需要写成 Example 9-12。

示例 9-12. 使用set_index的 Dask DataFrame 滚动窗口
divisions = pd.date_range(
    start="2021-01-01", end=datetime.today(), freq='7D').tolist()
partitioned_df_as_part_of_set_index = mini_sf_covid_df.set_index(
    'specimen_collection_date', divisions=divisions)
警告

Dask,包括滚动时间窗口,在处理时假定您的分区索引是单调递增的——严格递增,没有重复的值(例如,1、4、7 是单调递增的,但 1、4、4、7 不是)。

到目前为止,您必须指定分区的数量或特定的分区,但您可能想知道 Dask 是否可以自行确定这一点。幸运的是,Dask 的 repartition 函数可以从目标大小选择分区。然而,这样做是一个不小的成本,因为 Dask 必须评估 DataFrame 以及重新分区本身。示例 9-13 展示了如何让 Dask 根据所需的分区大小(以字节为单位)计算分区。

示例 9-13. Dask DataFrame 自动分区
reparted = indexed.repartition(partition_size="20kb")
警告

截至撰写时,Dask 的 set_index 有一个类似的 partition_size 参数,但尚不起作用。

在编写 DataFrames 时,每个分区都有自己的文件,但有时这可能导致文件过大或过小。有些工具只能接受单个文件作为输入,因此您需要将所有内容重新分区为单个分区。其他情况下,数据存储系统针对特定文件大小进行了优化,例如 Hadoop 分布式文件系统(HDFS)的默认块大小为 128 MB。好消息是,您可以使用 repartitionset_index 来获得所需的输出结构。

尴尬并行操作

Dask 的 map_partitions 函数将函数应用于每个底层 pandas DataFrame 的分区,结果也是一个 pandas DataFrame。使用 map_partitions 实现的函数由于不需要任何工作节点之间的数据传输,因此是尴尬并行的。在 尴尬并行问题 中,分布式计算和通信的开销很低。

map_partitions 实现了 map 和许多逐行操作。如果您想使用一个在逐行操作中找不到的函数,您可以像 示例 9-14 中所示自行实现它。

示例 9-14. Dask DataFrame fillna
def fillna(df):
    return df.fillna(value={"PostCode": "UNKNOWN"}).fillna(value=0)

new_df = df.map_partitions(fillna)
# Since there could be an NA in the index clear the partition/division information
new_df.clear_divisions()

您不仅限于像本例中调用 pandas 内置函数。只要您的函数接受并返回 DataFrame,您几乎可以在 map_partitions 中实现任何想做的事情。

本章节中无法覆盖完整的 pandas API,但如果一个函数可以在逐行处理时不需要了解前后的行,则可能已经在 Dask DataFrames 中使用 map_partitions 实现。如果没有,您也可以使用 示例 9-14 中的模式自行实现。

当在 DataFrame 上使用 map_partitions 时,您可以更改每行的任何内容,包括其分区的键。如果您更改了分区键中的值,您必须使用 clear_divisions 清除生成的 DataFrame 上的分区信息,或者使用 set_index 指定正确的索引,关于这一点您将在下一节中了解更多。

警告

不正确的分区信息可能导致不正确的结果,而不仅仅是异常,因为 Dask 可能会错过相关数据。

处理多个 DataFrames

pandas 和 Dask 有四个常见的函数用于合并 DataFrames。在根上是concat函数,它允许在任何轴上连接 DataFrames。在 Dask 中,连接 DataFrames 通常较慢,因为它涉及工作节点之间的通信。另外三个函数是joinmergeappend,它们都在concat之上实现了常见情况的特殊情况,并且具有略有不同的性能考虑。在处理多个 DataFrames 时,良好的分区和划分选择对性能有很大影响。

Dask 的joinmerge函数除了标准的 pandas 参数外,还有一个额外的可选参数。npartitions指定了目标输出分区的数量,但仅用于基于哈希的连接(您将在“多 DataFrame 内部”中了解到)。joinmerge会根据需要自动重新分区输入的 DataFrames。这很好,因为您可能不知道分区情况,但由于重新分区可能很慢,明确使用较低级别的concat函数可以帮助及早发现性能问题。Dask 的 join 在进行左连接或外连接时一次只能接受两个以上的 DataFrames。

提示

Dask 具有专门的逻辑来加速多 DataFrame 的连接,因此在大多数情况下,与a.join(b).join(c).join(d).join(e)相比,使用a.join([b, c, d, e])将更加有利。但是,如果您执行与小数据集的左连接,则第一种语法可能更高效。

当您通过行(类似于 SQL UNION)合并(通过concat)DataFrames 时,性能取决于要合并的 DataFrames 的分区是否有序。我们称一系列 DataFrames 的分区为有序,如果所有分区都是已知的,并且前一个 DataFrame 的最高分区低于下一个 DataFrame 的最低分区。如果任何输入具有未知分区,则 Dask 将生成一个没有已知分区的输出。具有所有已知分区时,Dask 将行基础的连接视为仅元数据更改,并且不会执行任何洗牌操作。这要求分区之间不存在重叠。此外,额外的interleave_partitions参数将行基础组合的连接类型更改为无输入分区限制,并将导致已知分区器。

Dask 的基于列的concat(类似于 SQL JOIN)在组合的 DataFrames 的分区/分割方面也有限制。Dask 的 concat 版本仅支持内部或完全外连接,不支持左连接或右连接。基于列的连接要求所有输入都有已知的分区器,并且结果是具有已知分区的 DataFrame。具有已知分区器对于后续连接非常有用。

警告

在对具有未知分区的 DataFrame 按行操作时,不要使用 Dask 的 concat,因为它可能会返回不正确的结果。Dask 假设索引是对齐的,如果没有索引,则不会存在。

多 DataFrame 内部

Dask 使用四种技术——哈希、广播、分区和 stack_partitions——来合并 DataFrame,每种技术的性能差异很大。Dask 根据索引、分区和请求的连接类型(例如,outer/left/inner)选择合适的技术。三种基于列的连接技术是哈希连接、广播连接和分区连接。在执行基于行的组合(例如 append)时,Dask 使用一种特殊的技术称为 stack_partitions,速度非常快。重要的是,您了解每种技术的性能以及引起 Dask 选择哪种方法的条件。

哈希 连接是 Dask 在没有其他适合的连接技术时使用的默认技术。哈希连接会为所有输入 DataFrame 的数据洗牌,以在目标键上进行分区。哈希连接使用键的哈希值,导致生成的 DataFrame 没有任何特定顺序。因此,哈希连接的结果没有已知的分区。

广播 连接非常适合将大 DataFrame 与小 DataFrame 进行连接。在广播连接中,Dask 将较小的 DataFrame 分发给所有工作进程。这意味着较小的 DataFrame 必须能够放入内存中。为了告诉 Dask 一个 DataFrame 适合广播,确保它全部存储在一个分区中,例如调用 repartition(npartitions=1)

分区 连接发生在沿着索引组合 DataFrame 时,其中所有 DataFrame 的分区都是已知的。由于输入分区已知,Dask 能够在 DataFrame 之间对齐分区,涉及的数据传输更少,因为每个输出分区具有比完整输入集更小的集合。

由于分区和广播连接速度更快,帮助 Dask 完成一些工作是值得的。例如,连接几个已知且对齐分区的 DataFrame,以及一个未对齐的 DataFrame,将导致昂贵的哈希连接。相反,尝试在剩余的 DataFrame 上设置索引并分区,或者先连接较便宜的 DataFrame,然后再执行昂贵的连接。

使用 stack_partitions 不同于所有其他选项,因为它不涉及任何数据的移动。相反,生成的 DataFrame 分区列表是输入 DataFrames 的上游分区的联合。Dask 在大多数基于行的组合中使用 stack​_partiti⁠ons,除非所有输入 DataFrame 的分区都已知且未正确排序,并且您请求 Dask interleave_partitions。当输入分区已知且正确排序时,stack_partitions 函数能够仅在其输出中提供已知分区。如果所有分区都已知但未正确排序,并且您设置了 interleave_partitions,Dask 将使用分区连接。虽然这种方法比较便宜,但并非免费,可能导致分区数量过多,需要重新分区。

缺失功能

并非所有的多 DataFrame 操作都已实现;compare 就是这样一个操作,这导致了关于 Dask DataFrames 限制的下一节。

什么不起作用

Dask 的 DataFrame 实现了大部分,但并非全部 pandas DataFrame API。由于开发时间的关系,某些 pandas API 在 Dask 中并未实现。其他部分则是为了避免暴露出意外缓慢的 API 而未使用。

有时 API 只是缺少一些小部分,因为 pandas 和 Dask 都在积极开发中。例如 split 函数就是一个例子。在本地 pandas 中,你可以调用 split(expand=true),而不是进行 split().explode。其中一些可以是参与并为 Dask 项目做贡献的好地方,如果您感兴趣的话。

有些库的并行效果不如其他库好。在这些情况下,一种常见的方法是尝试对数据进行足够的筛选或聚合,以便可以在本地表示数据,然后将本地库应用于数据。例如,在绘图时,通常会预先聚合计数或进行随机抽样,然后绘制结果。

虽然大部分 pandas DataFrame API 都可以直接使用,但在切换到 Dask DataFrame 之前,重要的是确保您有良好的测试覆盖率,以捕捉它不适用的情况。

什么更慢

通常情况下,使用 Dask DataFrames 可以提高性能,但并非总是如此。一般来说,较小的数据集在本地 pandas 中的表现更好。正如讨论的那样,任何涉及洗牌的操作在分布式系统中通常比在本地系统中慢。迭代算法还可以产生大量操作的图形,这些操作在 Dask 中评估速度比传统的贪婪评估要慢。

一些问题通常不适合数据并行计算。例如,使用具有更多并行写入者的单个锁的数据存储写入时会增加锁争用,并可能比单线程写入更慢。在这些情况下,你可以有时重新分区数据或将单个分区写入以避免锁争用。

处理递归算法

Dask 的惰性评估,由其谱系图提供支持,通常是有益的,使其能够自动合并步骤。然而,当图变得太大时,Dask 可能会在管理上遇到困难,这通常表现为驱动程序或笔记本变慢,有时会出现内存不足的异常。幸运的是,你可以通过将 DataFrame 写出并重新读入来解决这个问题。一般来说,Parquet 是执行此操作的最佳格式,因为它占用空间小且自描述,因此不需要进行模式推断。

其他函数有何不同

由于性能原因,Dask DataFrames 的各个部分与本地 DataFrames 行为略有不同。

reset_index

索引将在每个分区上重新从零开始。

kurtosis

不会过滤掉非数字(NaN)值并使用 SciPy 默认值。

concat

不同于强制执行类别类型,每个类别类型都扩展为其与所有连接的类别的并集。

sort_values

Dask 仅支持单列排序。

连接

当同时连接两个以上的 DataFrames 时,连接类型必须是 outer 或 left。

提示

如果你对深入了解 Dask 感兴趣,有几本专注于 Dask 的书籍正在积极开发中。本章中的大部分材料基于 Scaling Python with Dask

类似 pandas 的 Modin DataFrames

Modin,就像 Dask DataFrames 一样,旨在大部分替代 pandas DataFrames。Modin DataFrames 的整体性能与 Dask DataFrames 相似,但有几个注意事项。Modin 对内部的控制较少,这可能限制某些应用的性能。由于 Modin 和 Dask DataFrames 相似度足够高,我们在这里不会详细介绍,只是说如果 Dask 不能满足你的需求,Modin 是另一个选择。

注意

Modin 是一个旨在通过自动分布计算加速 pandas 的新库,可以利用系统上所有可用的 CPU 核心。Modin 声称可以几乎线性加速系统上 pandas DataFrames 的计算,无论大小如何。

由于 Modin 在 Ray 上与 Dask DataFrames 如此相似,我们决定跳过重复 Dask 在 Ray 上的示例,因为它们在本质上并无大变化。

警告

当你并排查看 Dask 和 Modin 的文档时,你可能会觉得 Dask 在其开发周期中较早。在我们看来,事实并非如此;相反,Dask 文档采用更为保守的方法来标记功能是否就绪。

使用 Spark 进行大数据处理

如果您正在使用现有的大数据基础设施(如 Apache Hive、Iceberg 或 HBase),Spark 是一个极好的选择。Spark 具有诸如过滤器推送等优化功能,可以显著提高性能。Spark 具有更传统的大数据 DataFrame 接口。

Spark 的强项在于其所属的数据生态系统。作为一个基于 Java 的工具,带有 Python API,Spark 与传统的大数据生态系统高度集成。Spark 支持最广泛的格式和文件系统,使其成为许多流水线初始阶段的优秀选择。

虽然 Spark 继续添加更多类似于 pandas 的功能,但其 DataFrame 最初是基于 SQL 设计的。您有几种选项可以了解 Spark,包括一些 O'Reilly 的书籍:Learning Spark by Jules Damji,High Performance Spark by Holden and Rachel Warren,以及Spark: The Definitive Guide by Bill Chambers and Matei Zaharia。

警告

与 Ray 不同,Spark 通常是惰性的,这意味着它不会在被强制之前评估数据。这可能会使调试变得具有挑战性,因为错误可能会出现在其根本原因几行之外。

使用本地工具

有些工具不太适合分布式操作。幸运的是,只要您的数据集足够小,您可以将其转换为各种本地进程格式。如果整个数据集可以放入内存中,to_pandasto_arrow是将数据集转换为本地对象的最简单方法。对于较大的对象,每个分区可能适合内存,但整个数据集可能不适合,iter_batches将为您提供一个生成器/迭代器,以逐个分区消耗数据。iter_batches函数接受batch_format参数,在pandaspyarrow之间进行切换。如果可能,pyarrow通常比pandas更高效。

使用内置 Ray Dataset 操作

除了允许您在各种工具之间传输数据外,Ray 还具有一些内置操作。Ray Datasets 并不试图匹配任何特定的现有 API,而是暴露基本的构建块,当现有库不满足您的需求时可以使用这些构建块。

Ray Datasets 支持基本的数据操作。Ray Datasets 并不旨在提供类似于 pandas 的 API;相反,它专注于提供基本的原语来构建。Dataset API 的功能受到启发,同时具有面向分区的函数。Ray 还最近添加了groupBy和聚合功能。

大多数数据集操作的核心构建块是 map_batches。默认情况下,map_batches 在构成数据集的块或批次上执行您提供的函数,并使用结果生成新数据集。map_batches 函数用于实现 filterflat_mapmap。通过查看将单词计数示例重写为直接使用 map_batches 的示例,您可以看到 map_batches 的灵活性,同时删除仅出现一次的单词,如 Example 9-15 所示。

Example 9-15. 使用 map_batches 进行 Ray 单词计数的示例,详见Ray word count with map_batches
def tokenize_batch(batch):
    nested_tokens = map(lambda s: s.split(" "), batch)
    # Flatten the result
    nr = []
    for r in nested_tokens:
        nr.extend(r)
    return nr

def pair_batch(batch):
    return list(map(lambda w: (w, 1), batch))

def filter_for_interesting(batch):
    return list(filter(lambda wc: wc[1] > 1, batch))

words = pages.map_batches(tokenize_batch).map_batches(pair_batch)
# The one part we can't rewrite with map_batches since it involves a shuffle
grouped_words = words.groupby(lambda wc: wc[0]) 
interesting_words = groupd_words.map_batches(filter_for_interesting)

map_batches 函数接受参数以定制其行为。对于有状态的操作,您可以将计算策略从默认的tasks更改为actors。前面的示例使用了默认格式,即 Ray 的内部格式,但您也可以将数据转换为pandaspyarrow。您可以在 Example 9-16 中看到 Ray 将数据转换为 pandas 的示例。

Example 9-16. 使用 Ray 的 map_batches 与 pandas 更新列的示例,详见Ray map_batches with pandas to update a column
# Kind of hacky string munging to get a median-ish to weight our values.
def update_empsize_to_median(df):
    def to_median(value):
        if " to " in value:
            f , t = value.replace(",", "").split(" to ")
            return (int(f) + int(t)) / 2.0
        elif "Less than" in value:
            return 100
        else:
            return 10000
    df["EmployerSize"] = df["EmployerSize"].apply(to_median)
    return df

ds_with_median = ds.map_batches(update_empsize_to_median, batch_format="pandas")
提示

您返回的结果必须是列表、pandaspyarrow,并且不需要与接收的相同类型匹配。

Ray 数据集没有内置的方法来指定要安装的附加库。您可以使用 map_batches 和任务来完成此操作,如 Example 9-17 所示,它安装额外的库以解析 HTML。

Example 9-17. 使用 Ray 的 map_batches 与额外库的示例,详见Using Ray map_batches with extra libraries
def extract_text_for_batch(sites):
    text_futures = map(lambda s: extract_text.remote(s), sites)
    result = ray.get(list(text_futures))
    # ray.get returns None on an empty input, but map_batches requires lists
    if result is None:
        return []
    return result

def tokenize_batch(texts):
    token_futures = map(lambda s: tokenize.remote(s), texts)
    result = ray.get(list(token_futures))
    if result is None:
        return []
    # Flatten the result
    nr = []
    for r in result:
        nr.extend(r)
    return nr

# Exercise for the reader: generalize the preceding patterns - 
# note the flatten magic difference

urls = ray.data.from_items(["http://www.holdenkarau.com", "http://www.google.com"])

pages = urls.map(fetch)

page_text = pages.map_batches(extract_text_for_batch)
words = page_text.map_batches(tokenize_batch)
word_count = words.groupby(lambda x: x).count()
word_count.show()

对于需要洗牌的操作,Ray 拥有 GroupedDataset,其行为略有不同。与其余的 Datasets API 不同,Ray 中的 groupby 是惰性评估的。groupby 函数接受列名或函数,其中具有相同值的记录将被聚合在一起。一旦您有了 GroupedDataset,您就可以将多个聚合传递给 aggregate 函数。Ray 的 AggregateFn 类在概念上类似于 Dask 的 Aggregation 类,只是它是按行操作的。由于它是按行操作的,所以当发现新的键值时,您需要提供一个 init 函数。对于每个新元素,您提供 accumulate 而不是每个新块提供 chunk。您仍然提供一种组合聚合器的方法,称为 merge 而不是 agg,两者都有可选的 finalize。为了理解差异,我们将 Dask 加权平均示例改写为 Ray,如 Example 9-18 所示。

Example 9-18. Ray 加权平均聚合的示例,详见Ray weighted average aggregation
def init_func(key):
    # First elem is weighted total, second is weights
    return [0, 0]

def accumulate_func(accumulated, row):
    return [
        accumulated[0] + 
        (float(row["EmployerSize"]) * float(row["DiffMeanHourlyPercent"])),
        accumulated[1] + row["DiffMeanHourlyPercent"]]

def combine_aggs(agg1, agg2):
    return (agg1[0] + agg2[0], agg1[1] + agg2[1])

def finalize(agg):
    if agg[1] != 0:
        return agg[0] / agg[1]
    else:
        return 0

weighted_mean = ray.data.aggregate.AggregateFn(
    name='weighted_mean',
    init=init_func,
    merge=combine_aggs,
    accumulate_row=accumulate_func, # Used to be accumulate
    # There is a higher performance option called accumulate_block for vectorized op
    finalize=finalize)
aggregated = ds_with_median.groupby("PostCode").aggregate(weighted_mean)
注意

使用 None 实现完整数据集聚合,因为所有记录都具有相同的键。

Ray 的并行控制不像 Dask 的索引或 Spark 的分区那样灵活。您可以控制目标分区的数量,但无法控制数据的分布方式。

注意

Ray 目前没有利用已知分区概念以最小化洗牌操作。

实现 Ray 数据集

Ray 数据集是使用您在前几章中使用的工具构建的。Ray 将每个数据集分割成许多较小的组件。这些较小的组件在 Ray 代码内部被称为 blockspartitions。每个分区包含一个 Arrow 数据集,表示整个 Ray 数据集的一个切片。由于 Arrow 不支持 Ray 的所有类型,如果有不支持的类型,每个分区还包含一个不支持类型的列表。

每个数据集内部的数据存储在标准的 Ray 对象存储中。由于 Ray 不能分割单个对象,每个分区都存储为一个单独的对象。这也意味着您可以将底层的 Ray 对象用作 Ray 远程函数和 actors 的参数。数据集包含对这些对象的引用以及模式信息。

提示

由于数据集包含模式信息,加载数据集会阻塞在第一个分区上,以便确定模式信息。其余分区会急切加载,但像 Ray 的其他操作一样不会阻塞。

与 Ray 的其余部分保持一致,数据集是不可变的。当您想对数据集执行操作时,您会应用一个转换,比如 filterjoinmap,Ray 返回一个包含结果的新数据集。

Ray 数据集可以使用任务(也称为远程函数)或者 actors 来进行转换处理。像 Modin 这样构建在 Ray 数据集之上的库依赖于使用 actors 进行处理,以便能够实现涉及状态的某些 ML 任务。

结论

Ray 在处理工具之间透明地处理数据移动方面表现出色,与传统技术相比,在工具之间的通信障碍要高得多。两个独立的框架,Modin 和 Dask,都在 Ray 数据集之上提供了类似于 pandas 的体验,这使得扩展现有的数据科学工作流程变得简单。在 Ray 数据集上的 Spark(称为 RayDP)为那些在具有现有大数据工具的组织中工作的人提供了一条简便的集成路径。

在本章中,您学会了如何使用 Ray 有效地处理数据,以支持您的机器学习和其他需求。在下一章中,您将学习如何使用 Ray 来支持机器学习。

¹ 这就像一家福特经销商建议购买福特车一样,所以接受这些建议时要持保留态度。

² 在 Dask 中,通过使用多线程可以避免原生代码中出现这个问题,但是具体细节超出了本书的范围。

³ 对精确分位数的备用算法依赖于更多的洗牌操作以减少空间开销。

⁴ 键偏斜可以使得已知的分区器无法执行此操作。

第十章:Ray 如何驱动机器学习

现在您已经牢固掌握了 Ray 中为准备数据以训练 ML 模型所需的一切。在本章中,您将学习如何使用流行的 Ray 库scikit-learnXGBoostPyTorch。本章不旨在介绍这些库,因此如果您对其中任何一个不熟悉,建议首先选择一个(我们建议选择 scikit-learn)进行阅读。即使对这些库熟悉的人也可以通过查阅自己喜欢的工具文档来刷新记忆。本章关注的是 Ray 如何用于驱动 ML,而不是 ML 的教程。

注意

如果您有兴趣深入学习 Ray 中的 ML,学习 Ray由 Max Pumperla 等人(O’Reilly)撰写的全长书籍专注于 ML 与 Ray,可以扩展您的 ML 技能组。

Ray 有两个内置的 ML 库。您将学习如何使用 Ray 的增强学习库RLlib,与 TensorFlow 一起使用,并通过Tune进行通用超参数调整,可以与任何 ML 库一起使用。

使用 scikit-learn 与 Ray

scikit-learn 是 ML 社区中使用最广泛的工具之一,提供数十种易于使用的 ML 算法。它最初由 David Cournapeau 在 2007 年作为 Google 夏季代码项目开发。通过一致的接口提供广泛的监督和无监督学习算法。

scikit-learn ML 算法包括以下内容:

聚类

用于对未标记数据进行分组,如 k 均值

监督模型

包括广义线性模型、判别分析、朴素贝叶斯、惰性方法、神经网络、支持向量机和决策树等

集成方法

用于组合多个监督模型的预测

scikit-learn 还包含重要的工具支持 ML:

交叉验证

用于评估监督模型在未见数据上的表现

数据集

用于测试数据集和生成具有特定属性的数据集,以研究模型行为

维度减少

用于减少数据中属性数量,以进行总结、可视化和特征选择,例如主成分分析

特征提取

用于定义图像和文本数据的属性

特征选择

用于识别用于创建监督模型的有意义的属性

参数调整

为了充分利用监督模型

流形学习

用于总结和描绘复杂的多维数据

虽然您可以直接在 Ray 中使用大多数 scikit-learn API 来调整模型的超参数,但当您想要并行执行时情况会变得有些复杂。

如果我们使用用于创建模型的基本代码来优化决策树的参数,我们的代码将如示例 10-1 所示。

示例 10-1. 使用 scikit-learn 构建我们的葡萄酒质量模型
# Get data
df = pd.read_csv("winequality-red.csv", delimiter=";")
print(f"Rows, columns: {str(df.shape)}")
print(df.head)
print(df.isna().sum())

# Create Classification version of target variable
df['goodquality'] = [1 if x >= 6 else 0 for x in df['quality']]
X = df.drop(['quality','goodquality'], axis = 1)
y = df['goodquality']
print(df['goodquality'].value_counts())

# Normalize feature variables
X_features = X
X = StandardScaler().fit_transform(X)
# Splitting the data
X_train, X_test, y_train, y_test = \
    train_test_split(X, y, test_size=.25, random_state=0)

param_model = {'max_depth':range(10, 20),
                'max_features': range(3,11)}

start = time.time()
model = GridSearchCV(DecisionTreeClassifier(random_state=1),
                     param_grid=param_model,
                     scoring='accuracy',
                     n_jobs=-1)

model = model.fit(X_train, y_train)
print(f"executed in {time.time() - start}, "
      f"nodes {model.best_estimator_.tree_.node_count}, "
      f"max_depth {model.best_estimator_.tree_.max_depth}")

y_pred = model.predict(X_test)
print(classification_report(y_test, y_pred))

请注意,在GridSearchCV中,我们使用参数n_jobs=-1,这会让实现在所有可用处理器上并行运行模型评估。¹ 即使在单机上并行运行模型评估也可以显著提高性能一个数量级。

不幸的是,这在 Ray 集群中并不能直接使用。GridSearchCV使用Joblib进行并行执行(和许多其他 scikit-learn 算法一样)。但是 Joblib 不能直接与 Ray 兼容。

Ray 实现了一个 Joblib 后端,采用 Ray actors 池(见第 4 章),而不是本地进程。这允许你简单地将 Joblib 后端更改为使用 Ray,从而使 scikit-learn 从使用本地进程切换到 Ray。

具体来说,要使示例 10-1 在 Ray 上运行,你需要注册 Ray 的 Joblib 后端,并在GridSearchCV执行中使用它,就像在示例 10-2 中一样。

示例 10-2. 使用 Ray Joblib 后端与 scikit-learn 构建葡萄酒质量模型
# Get data
df = pd.read_csv("winequality-red.csv", delimiter=";")
print(f"Rows, columns: {str(df.shape)}")
print(df.head)
print(df.isna().sum())

# Create Classification version of target variable
df['goodquality'] = [1 if x >= 6 else 0 for x in df['quality']]
X = df.drop(['quality','goodquality'], axis = 1)
y = df['goodquality']
print(df['goodquality'].value_counts())

# Normalize feature variables
X_features = X
X = StandardScaler().fit_transform(X)
# Splitting the data
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=.25, 
                                                    random_state=0)

param_model = {'max_depth':range(10, 20),
               'max_features': range(3,11)}

start = time.time()

mode = GridSearchCV(DecisionTreeClassifier(random_state=1),
                     param_grid=param_model,
                     scoring='accuracy',
                     n_jobs=-1)

register_ray()
with joblib.parallel_backend('ray'):
    model = mode.fit(X_train, y_train)

model = model.fit(X_train, y_train)
print(f"executed in {time.time() - start}, "
      f"nodes {model.best_estimator_.tree_.node_count}, "
      f"max_depth {model.best_estimator_.tree_.max_depth}")

y_pred = model.predict(X_test)
print(classification_report(y_test, y_pred))

使用 Boosting 算法与 Ray

由于 Boosting 算法训练多个模型,因此非常适合并行计算。你可以独立训练每个子模型,然后再训练另一个模型来组合结果。目前最流行的两个 Boosting 库如下:

XGBoost

一个优化的分布式梯度提升库,旨在高效、灵活和可移植。它在梯度提升框架下实现了机器学习算法。XGBoost 提供了并行树提升,也称为梯度提升决策树(GBDT)和梯度提升机(GBM),能够快速准确地解决许多数据科学问题。同样的代码可以运行在多种分布式环境上,包括 Hadoop、Sun Grid Engine(SGE)和消息传递接口(MPI),可以处理数十亿个示例以上的问题。

LightGBM

一个快速、分布式、高性能的梯度提升框架,基于决策树算法,用于排名、分类和许多其他机器学习任务。

我们将比较 Ray 如何并行训练 XGBoost 和 LightGBM,但是比较这两个库的细节超出了本书的范围。如果你对这两个库的区别感兴趣,可以参考 Sumit Saha 的“XGBoost vs. LighGBM: How Are They Different”

使用 XGBoost

继续我们的葡萄酒质量示例,我们使用 XGBoost 构建模型,并且相关代码在示例 10-3 中展示。

示例 10-3. 使用 XGBoost 构建我们的葡萄酒质量模型
# Get data
df = pd.read_csv("winequality-red.csv", delimiter=";")
print(f"Rows, columns: {str(df.shape)}")
print(df.head)
print(df.isna().sum())

# Create Classification version of target variable
df['goodquality'] = [1 if x >= 6 else 0 for x in df['quality']]
X = df.drop(['quality','goodquality'], axis = 1)
y = df['goodquality']
print(df['goodquality'].value_counts())

# Normalize feature variables
X_features = X
X = StandardScaler().fit_transform(X)
# Splitting the data
X_train, X_test, y_train, y_test = \
    train_test_split(X, y, test_size=.25, random_state=0)

start = time.time()
model = xgb.XGBClassifier(random_state=1)
model.fit(X_train, y_train)
print(f"executed XGBoost in {time.time() - start}")
y_pred = model.predict(X_test)
print(classification_report(y_test, y_pred))

XGBoost 之所以性能出色,部分原因在于它使用OpenMP来独立创建树分支,而这种方式并不直接支持 Ray。Ray 通过提供 xgboost-ray 库来与 XGBoost 集成,该库用 Ray actor 池替代了 OpenMP。您可以将此库用于 XGBoost 或 scikit-learn API。在后一种情况下,该库提供了以下估计器的一个插入式替代品:

  • RayXGBClassifier

  • RayXGRegressor

  • RayXGBRFClassifier

  • RayXGBRFRegressor

  • RayXGBRanker

它还提供了RayParams,允许您显式定义 Ray 的执行参数。使用这个库,我们可以修改示例 10-3,使其能够与 Ray 一起工作,如同示例 10-4。

示例 10-4. 使用 XGBoost Ray 库构建我们的葡萄酒质量模型
start = time.time()
model = RayXGBClassifier(
    n_jobs=10,  # In XGBoost-Ray, n_jobs sets the number of actors
    random_state=1
)

model.fit(X=X_train, y=y_train, ray_params=RayParams(num_actors=3))
print(f"executed XGBoost in {time.time() - start}")

在这里,我们使用RayParams来指定用于并行化的 Ray actor 池的大小。或者,您可以使用RayXGBClassifier中的n_jobs参数来实现相同的效果。

使用 LightGBM

我们展示了如何使用 LightGBM 构建我们的葡萄酒质量模型,详见示例 10-5。

示例 10-5. 使用 LightGBM 构建我们的葡萄酒质量模型
# Get data
df = pd.read_csv("winequality-red.csv", delimiter=";")
print(f"Rows, columns: {str(df.shape)}")
print(df.head)
print(df.isna().sum())

# Create Classification version of target variable
df['goodquality'] = [1 if x >= 6 else 0 for x in df['quality']]
X = df.drop(['quality','goodquality'], axis = 1)
y = df['goodquality']
print(df['goodquality'].value_counts())

# Normalize feature variables
X_features = X
X = StandardScaler().fit_transform(X)
# Splitting the data
X_train, X_test, y_train, y_test = \
    train_test_split(X, y, test_size=.25, random_state=0)

train_data = lgb.Dataset(X_train,label=y_train)
param = {'num_leaves':150, 'objective':'binary','learning_rate':.05,'max_bin':200}
param['metric'] = ['auc', 'binary_logloss']

start = time.time()
model = lgb.train(param,train_data,100)
print(f"executed LightGBM in {time.time() - start}")
y_pred = model.predict(X_test)

# Converting probabilities into 0 or 1

for i in range(len(y_pred)):
    if y_pred[i] >= .5:       # Setting threshold to .5
        y_pred[i] = 1
    else:
        y_pred[i] = 0
print(classification_report(y_test, y_pred))

类似于 XGBoost,LightGBM 使用 OpenMP 进行并行化。因此,Ray 提供了Distributed LightGBM on Ray library,该库使用 Ray 的 actor 池来实现并行化。类似于 xgboost-ray 库,此库支持原生和 scikit-learn API。在后一种情况下,该库实现了以下估计器的替代:

  • RayLGBMClassifier

  • RayLGBMRegressor

与 XGBoost 类似,RayParams提供了执行 Ray 所需的参数,用于定义 Ray 的执行参数。使用这个库,我们可以修改示例 10-5,使其能够与 Ray 一起工作,如同示例 10-6。

示例 10-6. 使用 LightGBM Ray 库构建我们的葡萄酒质量模型
model = RayLGBMClassifier(
    random_state=42)

start = time.time()
model.fit(X=X_train, y=y_train, ray_params=RayParams(num_actors=3))
print(f"executed LightGBM in {time.time() - start}")

在这里,我们使用RayParams来指定用于并行化的 Ray actor 池的大小。或者,您可以使用RayLGBMClassifier中的n_jobs参数来实现相同的效果。

使用 Ray 与 PyTorch

另一个非常流行的机器学习框架是PyTorch,这是由 Facebook 开发和维护的开源 Python 深度学习库。PyTorch 简单灵活,因此成为许多学术界和研究人员开发新的深度学习模型和应用的首选。

对于 PyTorch,已经实现了许多特定应用程序的扩展(如文本、计算机视觉和音频数据)。还有很多预训练模型可供直接使用。如果您对 PyTorch 不太熟悉,请参阅 Jason Brownlee 的PyTorch 教程,了解其结构、功能和解决各种问题的用法。

我们将继续解决葡萄酒质量问题,并展示如何使用 PyTorch 构建一个用于预测葡萄酒质量的多层感知器(MLP)模型。为此,您需要从创建一个可以扩展和定制以加载您的数据集的自定义 PyTorch Dataset 类 开始。对于我们的葡萄酒质量示例,自定义数据集类在 示例 10-7 中展示。

示例 10-7. 用于加载葡萄酒质量数据的 PyTorch 数据集类
# dataset
class WineQualityDataset(Dataset):
    # load the dataset
    def __init__(self, path):
        # load the csv file as a dataframe
        df = pd.read_csv(path, delimiter=";")
        print(f"Rows, columns: {str(df.shape)}")
        print(df.head)
        # create Classification version of target variable
        df['goodquality'] = [1 if x >= 6 else 0 for x in df['quality']]
        df = df.drop(['quality'], axis = 1)
        print(df['goodquality'].value_counts())
        # store the inputs and outputs
        self.X = StandardScaler().fit_transform(df.values[:, :-1])
        self.y = df.values[:, -1]
        # ensure input data is floats
        self.X = self.X.astype('float32')
        self.y = self.y.astype('float32')
        self.y = self.y.reshape((len(self.y), 1))

    # number of rows in the dataset
    def __len__(self):
        return len(self.X)

    # get a row at an index
    def __getitem__(self, idx):
        return [self.X[idx], self.y[idx]]

    # get indexes for train and test rows
    def get_splits(self, n_test=0.33):
        # determine sizes
        test_size = round(n_test * len(self.X))
        train_size = len(self.X) - test_size
        # calculate the split
        return random_split(self, [train_size, test_size])

请注意,除了最低要求之外,我们还实现了 get_splits,一个将原始数据集分成两个部分(一个用于训练,一个用于测试)的方法。

一旦您定义了数据类,就可以使用 PyTorch 制作模型。要在 PyTorch 中定义模型,您需要扩展基本的 PyTorch Module 类。为了我们的目的,模型类在 示例 10-8 中呈现。

示例 10-8. 用于葡萄酒质量的 PyTorch 模型类
# model definition
class WineQualityModel(Module):
    # define model elements
    def __init__(self, n_inputs):
        super(WineQualityModel, self).__init__()
        # input to first hidden layer
        self.hidden1 = Linear(n_inputs, 10)
        kaiming_uniform_(self.hidden1.weight, nonlinearity='relu')
        self.act1 = ReLU()
        # second hidden layer
        self.hidden2 = Linear(10, 8)
        kaiming_uniform_(self.hidden2.weight, nonlinearity='relu')
        self.act2 = ReLU()
        # third hidden layer and output
        self.hidden3 = Linear(8, 1)
        xavier_uniform_(self.hidden3.weight)
        self.act3 = Sigmoid()

    # forward-propagate input
    def forward(self, X):
        # input to first hidden layer
        X = self.hidden1(X)
        X = self.act1(X)
        # second hidden layer
        X = self.hidden2(X)
        X = self.act2(X)
        # third hidden layer and output
        X = self.hidden3(X)
        X = self.act3(X)
        return X

此类构造函数通过定义其层及其连接来构建模型。forward 方法定义了如何通过模型进行前向传播的方法。有了这两个类,整体代码看起来像是 示例 10-9。

示例 10-9. 葡萄酒质量模型构建的 PyTorch 实现
# ensure reproducibility
torch.manual_seed(42)
# load the dataset
dataset = WineQualityDataset("winequality-red.csv")

# calculate split
train, test = dataset.get_splits()
# prepare data loaders
train_dl = DataLoader(train, batch_size=32, shuffle=True)
test_dl = DataLoader(test, batch_size=32, shuffle=False)

# train the model
model = WineQualityModel(11)
# define the optimization
criterion = BCELoss()
optimizer = SGD(model.parameters(), lr=0.01, momentum=0.9)
start = time.time()
# enumerate epochs
for epoch in range(500):
    # enumerate mini batches
    for i, (inputs, targets) in enumerate(train_dl):
        # clear the gradients
        optimizer.zero_grad()
        # compute the model output
        yhat = model(inputs)
        # calculate loss
        loss = criterion(yhat, targets)
        # credit assignment
        loss.backward()
        # update model weights
        optimizer.step()
print(f"Build model in {time.time() - start}")
print(model)
# evaluate a model
predictions, actuals = list(), list()
for i, (inputs, targets) in enumerate(test_dl):
    # evaluate the model on the test set
    yhat = model(inputs)
    # retrieve numpy array
    yhat = yhat.detach().numpy()
    actual = targets.numpy()
    actual = actual.reshape((len(actual), 1))
    # round to class values
    yhat = yhat.round()
    # store
    predictions.append(yhat)
    actuals.append(actual)
predictions, actuals = vstack(predictions), vstack(actuals)
# calculate accuracy
acc = accuracy_score(actuals, predictions)
print("Model accuracy", acc)

示例 10-9 可以运行,但 Ray 与 Lightning(之前称为 PyTorch Lightning)集成,而不是 PyTorch。 Lightning 会结构化您的 PyTorch 代码,使其可以抽象出训练的细节。这使得 AI 研究可扩展且快速迭代。

要将 示例 10-9 转换为 Lightning,我们首先需要修改 示例 10-8。在 Lightning 中,它需要派生自 lightning_module,而不是 module,这意味着我们需要向我们的模型添加两种方法(示例 10-10)。

示例 10-10. Lightning 模型额外功能用于葡萄酒质量
    # training step
    def training_step(self, batch, batch_idx):
        x, y = batch
        y_hat = self(x)
        loss = self.bce(y_hat, y)
        return loss
    # optimizer
    def configure_optimizers(self):
        return Adam(self.parameters(), lr=0.02)

这里的 training_step 方法定义了一个单独的步骤,而 configure_optimized 定义了要使用的优化器。当您将此与 示例 10-8 进行比较时,您会注意到某些例子的代码已经移到了这两个方法中(这里我们使用 Adam 优化器而不是 BCELoss 优化器)。有了这个更新的模型类,模型训练看起来像是 示例 10-11。

示例 10-11. 葡萄酒质量模型构建的 Lightning 实现
# train
trainer = Trainer(max_steps=1000)
trainer.fit(model, train_dl)

请注意,与 示例 10-9 不同,其中训练是以编程方式实现的,Lightning 引入了一个 trainer 类,该类在内部实现了一个 trainer 循环。这种方法允许所有所需的优化都在训练循环中进行。

PyTorch 和 Lightning 都使用 Joblib 通过内置的 ddp_cpu 后端或更普遍地说,Horovod 分发训练。与其他库一样,为了允许 Ray 上的分布式 Lightning,Ray 有一个库 分布式 PyTorch Lightning 训练,它为使用 Ray 进行分布式训练添加了新的 Lightning 插件。这些插件允许您快速轻松地并行化训练,同时仍然获得 Lightning 的所有好处,并使用您想要的训练协议,无论是 ddp_cpu 还是 Horovod。

一旦将插件添加到 Lightning 训练器中,您就可以将它们配置为将训练并行化到笔记本电脑的所有核心,或跨大规模多节点、多 GPU 集群,而无需额外的代码更改。此库还与 Ray Tune 集成,因此您可以执行分布式超参数调整实验。

RayPlugin 类提供了在 Ray 集群上的分布式数据并行(DDP)训练。PyTorch DDP 被用作 PyTorch 的分布式训练协议,而在这种情况下,Ray 被用于启动和管理训练工作进程。使用此插件的基本代码如 示例 10-12 所示。

示例 10-12. 使我们的葡萄酒质量模型构建的 Lightning 实现在 Ray 上运行
# train
plugin = RayPlugin(num_workers=6)
trainer = Trainer(max_steps=1000, plugins=[plugin])
trainer.fit(model, train_dl)
print(f"Build model in {time.time() - start}")
print(model)

该库中包含的另外两个插件如下:

HorovodRayPlugin

与 Horovod 集成为分布式训练协议。

RayShardedPlugin

FairScale 集成,以在 Ray 集群上提供分片 DDP 训练。通过分片训练,您可以利用数据并行训练的可伸缩性,同时大大减少训练大型模型时的内存使用。

使用 Ray 的强化学习

Ray 最初是作为强化学习(RL)的平台而创建的,这是现代人工智能领域最热门的研究课题之一,其受欢迎程度仅增不减。RL 是一种机器学习技术,它使代理能够在交互式环境中通过试错学习,利用自身的行动和经验反馈;参见 图 10-1。

spwr 1001

图 10-1. 机器学习的类型

监督学习和强化学习都创建了输入和输出之间的映射。但是,监督学习使用一组已知的输入和输出进行训练,而强化学习使用奖励和惩罚作为正面和负面行为的信号。无监督学习和强化学习都利用实验数据,但它们有不同的目标。在无监督学习中,我们正在寻找数据点之间的相似性和差异,而在强化学习中,我们试图找到一个合适的动作模型,以最大化总累积奖励并改进模型。

RL 实现的关键组件如下,并且在图 10-2 中有所描绘:

环境

代理操作的物理世界

状态

代理的当前状态

奖励

从环境中向代理提供的反馈

策略

将代理状态映射到行动的方法

价值

代理在特定状态下执行行动后会收到的未来奖励

spwr 1002

图 10-2. 强化学习模型实现

RL 是一个广泛的主题,其详细信息超出了本书的范围(我们只是试图用一个简单的例子来解释如何开始使用该库),但如果您有兴趣了解更多,Shweta Bhatt 的“强化学习 101” 是一个很好的起点。

Ray 的 RLlib 是一个 RL 库,允许生产级高度分布式 RL 工作负载,同时为不同行业的各种应用提供统一且简单的 API。它支持无模型基于模型的强化学习。

如图 10-3 所示,RLlib 是建立在 Ray 之上的,提供现成的高度分布式算法、策略、损失函数和默认模型。

spwr 1003

图 10-3. RLlib 组件

策略 封装了 RL 算法的核心数值组件。它包括一个策略模型,根据环境变化确定行动,并且定义一个损失函数,根据后处理的环境确定行动的结果。根据环境的不同,RL 可以有单个代理和属性,多个代理的单一策略,或多个策略,每个策略控制一个或多个代理。

代理与之交互的一切都称为环境。环境是外部世界,包括代理外的一切。

在给定环境和策略的情况下,策略评估由工作器完成。RLlib 提供了一个RolloutWorker 类,在大多数 RLlib 算法中都会使用。

在高层次上,RLlib 提供了训练器类,用于持有环境交互的策略。通过训练器接口,可以训练策略、创建检查点或计算行动。在多代理训练中,训练器管理多个策略的查询和优化。训练器类通过利用 Ray 的并行迭代器来协调运行回合和优化策略的分布式工作流程。

除了 Python 中定义的环境外,Ray 还支持在 离线数据集 上进行批处理训练,通过 输入读取器。这对于 RL 是一个重要的用例,当无法在传统的训练和模拟环境(如化工厂或装配线)中运行时,适当的模拟器不存在。在这种方法中,过去活动的数据用于训练策略。

从单个进程到大型集群,RLlib 中所有数据交换都使用 样本批次。样本批次编码一个或多个数据片段。通常,RLlib 从 rollout workers 收集 rollout_fragment_length 大小的批次,并将其中一个或多个批次连接成 train_batch_size 大小的批次,这是随机梯度下降(SGD)的输入。

RLlib 的主要特性如下:

  • 支持最流行的深度学习框架,包括 PyTorch 和 TensorFlow。

  • 实施高度分布式学习时,RLlib 算法——PPOIMPALA——允许您设置 num_workers 配置参数,使得您的工作负载可以在数百个 CPU 或节点上运行,从而并行化并加速学习。

  • 支持 多智能体 RL,允许训练支持以下任何策略的代理。

  • 支持外部可插拔模拟器环境的 API,配备了一个可插拔的、现成的 客户端服务器 设置,允许您在“外部”运行数百个独立的模拟器,连接到中央的 RLlib 策略服务器,学习并提供动作。

此外,RLlib 提供了简单的 API 来定制训练和实验工作流的所有方面。例如,您可以通过使用 OpenAI’s GymDeepMind’s OpenSpiel 编写自己的 Python 环境,提供自定义的 TensorFlow/KerasPyTorch 模型,并编写自己的 策略和损失定义 或定义自定义的 探索行为

实现 RL 训练的简单代码,以解决倒立摆问题——即 CartPole——(该环境存在于 OpenAI 的 Gym 中),如 Example 10-13 所示。

示例 10-13. CartPole 强化学习
ray.init()
config = {
    # Environment (RLlib understands OpenAI Gym registered strings).
    'env': 'CartPole-v0',
    # Use 4 environment workers (aka "rollout workers") that parallelly
    # collect samples from their own environment clone(s).
    "num_workers": 4,
    'framework': 'tf2',
    'eager_tracing': True,
    # This is just our model arch, choosing the right one is beyond the scope
    # of this book.
    'model': {
        'fcnet_hiddens': [64, 64],
        'fcnet_activation': 'relu',
    },
    # Set up a separate evaluation worker set for the
    # `trainer.evaluate()` call after training.
    'evaluation_num_workers': 1,
    # Only for evaluation runs, render the env.
    'evaluation_config': {
        "render_env": True,
    },
    'gamma': 0.9,
    'lr': 1e-2,
    'train_batch_size': 256,
}

# Create RLlib Trainer.
trainer = agents.ppo.PPOTrainer(config=config)

# Run it for n training iterations. A training iteration includes
# parallel sample collection by the environment workers as well as
# loss calculation on the collected batch and a model update.
for i in range(5):
    print(f"Iteration {i}, training results {trainer.train()}")

# Evaluate the trained Trainer (and render each timestep to the shell's
# output).
trainer.evaluate()

示例 10-13 从创建一个训练器的配置开始。配置定义了一个环境,³,工作者数量(我们使用四个),框架(我们使用 TensorFlow 2),模型,训练批量大小和其他执行参数。此配置用于创建训练器。然后我们执行几个训练迭代并显示结果。这就是实现简单 RL 所需要的全部。

你可以通过创建你自己的环境或引入你自己的算法,轻松扩展这个简单的例子。

Michael Galarnyk 在 “Best Reinforcement Learning Talks from Ray Summit 2021” 中描述了 Ray RLlIB 的许多用法示例。

使用 Ray 进行超参数调整

在创建 ML 模型时,你经常面临各种选择,从模型类型到特征选择技术。ML 的一个自然扩展是使用类似的技术来找到建立模型时的正确值(或参数)。定义模型架构的参数称为超参数,搜索理想模型架构的过程称为超参数调整。与指定如何将输入数据转换为期望输出的模型参数不同,超参数定义了如何构造模型。

与提升算法一样,超参数调整特别适合并行化,因为它涉及训练和比较许多模型。根据搜索技术,训练这些单独的模型可能是一个“尴尬地并行”的问题,因为它们之间几乎不需要通信。

这里是一些超参数的示例:

  • 应该为线性模型使用的多项式特征的程度

  • 决策树允许的最大深度

  • 决策树叶节点所需的最小样本数

  • 神经网络层的神经元数量

  • 神经网络的层数

  • 梯度下降的学习率

Ray Tune 是基于 Ray 的超参数调整本地库。Tune 的主要特点如下:

  • 它提供了分布式、异步优化的功能,利用了 Ray。

  • 同样的代码可以从单机扩展到大型分布式集群。

  • 它提供了包括(但不限于)ASHABOHBPopulation-Based Training 在内的最先进的算法。

  • 它与 TensorBoardMLflow 集成以可视化调整结果。

  • 它与许多优化库集成,如 Ax/BotorchHyperoptBayesian Optimization,并使它们能够透明地扩展。

  • 它支持许多 ML 框架,包括 PyTorch、TensorFlow、XGBoost、LightGBM 和 Keras。

下面是 Tune 的主要组件:

Trainable

一个训练函数,具有一个目标函数。Tune 为 trainable 提供了两个接口 API:函数式和类式。

搜索空间

您的超参数的有效值,可以指定这些值如何被采样(例如,从均匀分布或正态分布中)。Tune 提供各种功能来定义搜索空间和采样方法。

搜索算法

用于超参数优化的算法。Tune 具有与许多流行优化库集成的搜索算法,例如NevergradHyperopt。Tune 自动将提供的搜索空间转换为搜索算法/底层库期望的搜索空间。

Trial

执行或运行一个单一超参数配置的逻辑表示。每个试验与 trainable 实例相关联。一组试验组成一个实验。Tune 使用 Ray actors 作为工作节点的进程,以并行运行多个试验。

实验分析

一个由 Tune 返回的对象,具有可用于分析训练的方法。它可以与 TensorBoard 和 MLflow 集成以进行结果可视化。

为了展示如何使用 Tune,让我们优化我们的 PyTorch 葡萄酒质量模型构建实现(示例 10-8)。我们将尝试优化用于构建模型的优化器的两个参数:lrmomentum

首先,我们重新组织我们的代码(示例 10-9),引入了三个额外的函数(示例 10-14)。

示例 10-14. 为我们的 PyTorch 葡萄酒质量模型实现支持函数
# train function
def model_train(model, optimizer, criterion, train_loader):
    # for every mini batch
    for i, (inputs, targets) in enumerate(train_loader):
        # clear the gradients
        optimizer.zero_grad()
        # compute the model output
        yhat = model(inputs)
        # calculate loss
        loss = criterion(yhat, targets)
        # credit assignment
        loss.backward()
        # update model weights
        optimizer.step()

# test model
def model_test(model, test_loader):
    predictions, actuals = list(), list()
    for i, (inputs, targets) in enumerate(test_loader):
        # evaluate the model on the test set
        yhat = model(inputs)
        # retrieve numpy array
        yhat = yhat.detach().numpy()
        actual = targets.numpy()
        actual = actual.reshape((len(actual), 1))
        # round to class values
        yhat = yhat.round()
        # store
        predictions.append(yhat)
        actuals.append(actual)
    predictions, actuals = vstack(predictions), vstack(actuals)
    # calculate accuracy
    return accuracy_score(actuals, predictions)

# train wine quality model
def train_winequality(config):

    # calculate split
    train, test = dataset.get_splits()
    train_dl = DataLoader(train, batch_size=32, shuffle=True)
    test_dl = DataLoader(test, batch_size=32, shuffle=False)

    # model
    model = WineQualityModel(11)
    # define the optimization
    criterion = BCELoss()
    optimizer = SGD(
        model.parameters(), lr=config["lr"], momentum=config["momentum"])
    for i in range(50):
        model_train(model, optimizer, criterion, train_dl)
        acc = model_test(model, test_dl)

        # send the current training result back to Tune
        tune.report(mean_accuracy=acc)

        if i % 5 == 0:
            # this saves the model to the trial directory
            torch.save(model.state_dict(), "./model.pth")

在这段代码中,我们引入了三个支持函数:

model_train

封装了模型训练。

model_test

封装了模型质量评估。

train_winequality

实现了模型训练的所有步骤,并向 Tune 报告它们。这使得 Tune 能够在训练过程中做出决策。

有了这三个函数,与 Tune 的集成非常简单(示例 10-15)。

示例 10-15. 将模型构建与 Tune 集成
# load the dataset
dataset = WineQualityDataset("winequality-red.csv")

search_space = {
    "lr": tune.sample_from(lambda spec: 10**(-10 * np.random.rand())),
    "momentum": tune.uniform(0.1, 0.9)
}

analysis = tune.run(
    train_winequality,
    num_samples=100,
    scheduler=ASHAScheduler(metric="mean_accuracy", mode="max"),
    config=search_space
)

加载数据集后,代码定义了一个搜索空间——可能的超参数空间——并通过使用train_winequality方法进行调优。这里的参数如下:

可调用的

定义了一个训练函数(在我们的案例中是train_winequality)。

num_samples

指示 Tune 的最大运行次数。

scheduler

在这里,我们使用ASHA,一种用于原则性提前停止的可扩展算法。为了使优化过程更有效,ASHA 调度器终止不太有前途的试验,并为更有前途的试验分配更多时间和资源。

config

包含算法的搜索空间。

运行上述代码会生成 示例 10-16 中显示的结果。

示例 10-16. 调整模型结果
+-------------------------------+------------+-----------------+------------- ...

| Trial name | status | loc | lr | momentum | acc | iter | total time (s) |

|-------------------------------+------------+-----------------+------------- ...

| ...00000 | TERMINATED | ... | 2.84411e-07 | 0.170684 | 0.513258 | 50 | 4.6005 |

| ...00001 | TERMINATED | ... | 4.39914e-10 | 0.562412 | 0.530303 | 1 | 0.0829589 |

| ...00002 | TERMINATED | ... | 5.72621e-06 | 0.734167 | 0.587121 | 16 | 1.2244 |

| ...00003 | TERMINATED | ... | 0.104523 | 0.316632 | 0.729167 | 50 | 3.83347 |

……………………………..

| ...00037 | TERMINATED | ... | 5.87006e-09 | 0.566372 | 0.625 | 4 | 2.41358 |

|| ...00043 | TERMINATED | ... | 0.000225694 | 0.567915 | 0.50947 | 1 | 0.130516 |

| ...00044 | TERMINATED | ... | 2.01545e-07 | 0.525888 | 0.405303 | 1 | 0.208055 |

| ...00045 | TERMINATED | ... | 1.84873e-07 | 0.150054 | 0.583333 | 4 | 2.47224 |

| ...00046 | TERMINATED | ... | 0.136969 | 0.567186 | 0.742424 | 50 | 4.52821 |

| ...00047 | TERMINATED | ... | 1.29718e-07 | 0.659875 | 0.443182 | 1 | 0.0634422 |

| ...00048 | TERMINATED | ... | 0.00295002 | 0.349696 | 0.564394 | 1 | 0.107348 |

| ...00049 | TERMINATED | ... | 0.363802 | 0.290659 | 0.725379 | 4 | 0.227807 |

+-------------------------------+------------+-----------------+------------- ...

正如您所看到的,虽然我们为模型搜索定义了 50 次迭代,但使用 ASHA 显着提高了性能,因为平均使用的运行次数显著减少(在这个例子中,超过 50% 的情况下只使用了一次迭代)。

结论

在本章中,您了解了如何利用 Ray 构建用于扩展执行不同 ML 库(scikit-learn、XGBoost、LightGBM 和 Lightning)的能力的多机器 Ray 集群的方法。

我们向您展示了将现有的 ML 代码移植到 Ray 的简单示例,以及 Ray 如何扩展 ML 库以实现扩展的内部工作原理。我们还展示了使用 Ray 特定实现的 RL 和超参数调整的简单示例。

我们希望通过查看这些相对简单的示例,您能更好地了解如何在日常实现中最好地使用 Ray。

¹ 在这个例子中,我们使用了 GridSearchCV,它实现了一种穷举搜索。虽然这对于这个简单的例子有效,但 scikit-learn 目前提供了一个新的库,Tune-sklearn,它提供了更强大的 调整算法 来显著提高调整速度。这意味着,相同的 Joblib 后端对这些算法的工作方式都是一样的。

² 在我们的测试中,对于 XGBoost,执行时间为 0.15 秒,而对于 LightGBM,执行时间为 0.24 秒。

³ 在这里,我们使用了现有的 OpenAI Gym 环境,因此我们只需使用其名称即可。

第十一章:使用 Ray 与 GPU 和加速器

虽然 Ray 主要专注于水平扩展,但有时使用像 GPU 这样的特殊加速器可能比仅仅投入更多“常规”计算节点更便宜和更快。GPU 特别适合执行向量化操作,一次对数据块执行相同操作。机器学习,以及更广泛的线性代数,是一些顶级用例,¹ 因为深度学习极易向量化。

通常情况下,GPU 资源比 CPU 资源更昂贵,因此 Ray 的架构使得在必要时仅需请求 GPU 资源变得更加容易。要利用 GPU,您需要使用专门的库,并且由于这些库涉及直接内存访问,它们的结果可能并不总是可串行化的。在 GPU 计算世界中,NVIDIA 和 AMD 是两个主要选项,具有不同的集成库。

GPU 擅长什么?

并非每个问题都适合 GPU 加速。GPU 特别擅长同时在许多数据点上执行相同计算。如果一个问题非常适合向量化,那么 GPU 可能非常适合解决这个问题。

以下是从 GPU 加速中受益的常见问题:

  • 机器学习

  • 线性代数

  • 物理学模拟

  • 图形(这不奇怪)

GPU 不适合分支密集的非向量化工作流程,或者数据复制成本与计算成本相似或更高的工作流程。

构建模块

使用 GPU 需要额外的开销,类似于分发任务的开销(尽管速度稍快)。这些开销来自于数据串行化以及通信,尽管 CPU 和 GPU 之间的链接通常比网络链接更快。与 Ray 的分布式任务不同,GPU 没有 Python 解释器。相反,您的高级工具通常会生成或调用本机 GPU 代码。CUDA 和 Radeon Open Compute(ROCm)是与 GPU 交互的两个事实上的低级库,分别来自 NVIDIA 和 AMD。

NVIDIA 首先发布了 CUDA,并迅速在许多高级库和工具中获得了广泛应用,包括 TensorFlow。AMD 的 ROCm 起步较慢,并未见到同样程度的采纳。一些高级工具,包括 PyTorch,现在已经集成了 ROCm 支持,但许多其他工具需要使用特殊分支的 ROCm 版本,例如 TensorFlow(tensorflow-rocm)或 LAPACK(rocSOLVER)。

弄清楚构建模块可能会出人意料地具有挑战性。例如,在我们的经验中,让 NVIDIA GPU Docker 容器在 Linux4Tegra 上与 Ray 构建需要几天的时间。 ROCm 和 CUDA 库有支持特定硬件的特定版本,同样,您可能希望使用的更高级程序可能仅支持某些版本。如果您正在运行 Kubernetes 或类似的容器化平台,则可以从像 NVIDIA 的 CUDA 映像 或 AMD 的 ROCm 映像 这样的预构建容器开始受益,作为基础。

更高级的库

除非您有特殊需求,否则您可能会发现与为您生成 GPU 代码的更高级库一起工作最容易,例如基本线性代数子程序(BLAS)、TensorFlow 或 Numba。您应尝试将这些库安装在您正在使用的基础容器或机器映像中,因为它们在安装期间通常需要大量编译时间。

一些库,例如 Numba,执行动态重写您的 Python 代码。要使 Numba 对您的代码起作用,您需要在函数中添加装饰器(例如 @numba.jit)。不幸的是,numba.jit 和其他对函数的动态重写在 Ray 中不受直接支持。相反,如果您使用此类库,只需像示例 11-1 中所示包装调用即可。

示例 11-1. 简单的 CUDA 示例
from numba import cuda, float32

# CUDA kernel
@cuda.jit
def mul_two(io_array):
    pos = cuda.grid(1)
    if pos < io_array.size:
        io_array[pos] *= 2 # do the computation

@ray.remote
def remote_mul(input_array):
    # This implicitly transfers the array into the GPU and back, which is not free
    return mul_two(input_array)
注意

与 Ray 的分布式函数类似,这些工具通常会为您处理数据复制,但重要的是要记住移动数据进出 GPU 不是免费的。由于这些数据集可能很大,大多数库尝试在相同数据上执行多个操作。如果您有一个重复使用数据的迭代算法,则使用 actor 来持有 GPU 资源并将数据保留在 GPU 中可以减少此成本。

无论您选择哪个库(或者是否决定编写自己的 GPU 代码),都需要确保 Ray 将您的代码调度到具有 GPU 的节点上。

获取和释放 GPU 和加速器资源

您可以通过将 num_gpus 添加到 ray.remote 装饰器来请求 GPU 资源,与内存和 CPU 的方式类似。与 Ray 中的其他资源(包括内存)一样,Ray 中的 GPU 不能保证,并且 Ray 不会自动为您清理资源。虽然 Ray 不会自动为您清理内存,但 Python(在一定程度上)会,这使得 GPU 泄漏比内存泄漏更有可能。

许多高级库在 Python VM 退出之前不会释放 GPU。您可以在每次调用后强制 Python VM 退出,从而释放任何 GPU 资源,方法是在您的 ray.remote 装饰器中添加 max_calls=1,如示例 11-2 所示。

示例 11-2. 请求和释放 GPU 资源
# Request a full GPU, like CPUs we can request fractional
@ray.remote(num_gpus=1)
def do_serious_work():
# Restart entire worker after each call
@ray.remote(num_gpus=1, max_calls=1)
def do_serious_work():

重新启动的一个缺点是它会移除你在 GPU 或加速器中重用现有数据的能力。你可以通过使用长生命周期的 actors 来解决这个问题,但这会牺牲资源在这些 actors 中的锁定。

Ray 的机器学习库

你也可以配置 Ray 的内置机器学习库来使用 GPU。为了让 Ray Train 启动 PyTorch 使用 GPU 资源进行训练,你需要在Trainer构造函数调用中设置use_gpu=True,就像你配置工作进程数量一样。Ray Tune 为资源请求提供了更大的灵活性,你可以在tune.run中指定资源,使用与ray.remote相同的字典。例如,要在每次试验中使用两个 CPU 和一个 GPU,你会调用tune.run(trainable, num_samples=10, resources_per_trial={"cpu": 2, "gpu": 2})

带有 GPU 和加速器的自动缩放器

Ray 的自动缩放器具有理解不同类型节点并选择基于请求资源调度的能力。这在 GPU 方面尤为重要,因为 GPU 比其他资源更昂贵(且供应更少)。在我们的集群中,由于只有四个带有 GPU 的节点,我们配置自动缩放器如下(oreil.ly/juA4y):

imagePullSecrets: []
# In practice you _might_ want an official Ray image
# but this is for a bleeding-edge mixed arch cluster,
# which still is not fully supported by Ray's official
# wheels & containers.
image: holdenk/ray-ray:nightly
operatorImage: holdenk/ray-ray:nightly
podTypes:
  rayGPUWorkerType:
    memory: 10Gi
    maxWorkers: 4
    minWorkers: 1
# Normally you'd ask for a GPU but NV auto labeler is...funky on ARM
    CPU: 1
    rayResources:
      CPU: 1
      GPU: 1
      memory: 1000000000
    nodeSelector:
      node.kubernetes.io/gpu: gpu
  rayWorkerType:
    memory: 10Gi
    maxWorkers: 4
    minWorkers: 1
    CPU: 1
  rayHeadType:
    memory: 3Gi
    CPU: 1

这样,自动缩放器可以分配不带 GPU 资源的容器,从而使 Kubernetes 能够将这些 pod 放置在仅 CPU 节点上。

CPU 回退作为一种设计模式

大多数可以通过 GPU 加速的高级库也具有 CPU 回退功能。Ray 没有内置的表达 CPU 回退或“如果可用则使用 GPU”的方法。在 Ray 中,如果你请求一个资源,调度程序找不到它,并且自动缩放器无法为其创建一个实例,该函数或 actor 将永远阻塞。通过一些创意,你可以在 Ray 中构建自己的 CPU 回退代码。

如果你希望在集群有 GPU 资源时使用它们,并在没有 GPU 时回退到 CPU,你需要做一些额外的工作。确定集群是否有可用的 GPU 资源的最简单方法是请求 Ray 运行一个带有 GPU 的远程任务,然后基于此设置资源,如示例 11-3 所示。

示例 11-3. 如果不存在 GPU,则回退到 CPU
# Function that requests a GPU
@ray.remote(num_gpus=1)
def do_i_have_gpus():
    return True

# Give it at most 4 minutes to see if we can get a GPU
# We want to give the autoscaler some time to see if it can spin up
# a GPU node for us.
futures = [do_i_have_gpus.remote()]
ready_futures, rest_futures = ray.wait(futures, timeout=240)

resources = {"num_cpus": 1}
# If we have a ready future, we have a GPU node in our cluster
if ready_futures:
    resources["num_gpus"] =1

# "splat" the resources
@ray.remote(** resources)
def optional_gpu_task():

你使用的任何库也需要回退到基于 CPU 的代码。如果它们不会自动这样做(例如,根据 CPU 与 GPU 调用不同的两个函数,如mul_two_cudamul_two_np),你可以通过传递一个布尔值来指示集群是否有 GPU。

警告

如果 GPU 资源没有得到正确释放,这仍可能导致在 GPU 集群上失败。理想情况下,你应该修复 GPU 释放问题,但在多租户集群上,这可能不是一个选项。你还可以在每个函数内部尝试/捕获获取 GPU 的异常。

其他(非 GPU)加速器

尽管本章的大部分内容集中在 GPU 加速器上,但相同的一般技术也适用于其他类型的硬件加速。例如,Numba 能够利用特殊的 CPU 特性,而 TensorFlow 则可以利用张量处理单元(TPU)。在某些情况下,资源可能不需要代码更改,而只是通过相同的 API 提供更快的性能,例如具有非易失性存储器快速执行(NVMe)驱动器的机器。在所有这些情况下,您可以配置自动扩展器来标记并使这些资源可用,方式与 GPU 类似。

结论

GPU 是在 Ray 上加速某些工作流程的绝佳工具。虽然 Ray 本身没有用于利用 GPU 加速代码的钩子,但它与各种您可以用于 GPU 计算的库集成良好。许多这些库并非为共享计算而创建,因此需要特别注意意外资源泄漏,尤其是由于 GPU 资源往往更昂贵。

¹ 其他顶级用例之一是加密货币挖矿,但你不需要像 Ray 这样的系统。使用 GPU 进行加密货币挖矿导致需求增加,许多显卡售价高于官方价格,而 NVIDIA 已经在尝试用其最新的 GPU 抑制加密货币挖矿

第十二章:Ray 在企业中

在企业环境中部署软件通常需要满足额外的要求,特别是在安全方面。企业部署往往涉及多个利益相关者,并且需要为更大的科学家/工程师群体提供服务。虽然不是必需的,但许多企业集群往往具有某种形式的多租户性质,以允许更有效地利用资源(包括人力资源,如运营人员)。

Ray 依赖项安全问题

不幸的是,Ray 的默认要求文件引入了一些不安全的库。许多企业环境都有某种容器扫描或类似系统来检测此类问题¹。在某些情况下,您可以简单地删除或升级标记的依赖项问题,但当 Ray 将依赖项包含在其 wheel 中时(例如,Apache Log4j 问题),限制自己使用预构建的 wheel 会有严重的缺点。如果发现 Java 或本地库有问题,则需要使用升级版本从源代码重新构建 Ray。Derwen.ai 在其ray_base repo中有一个关于在 Docker 中执行此操作的示例。

与现有工具进行交互

企业部署通常涉及与现有工具及其产生的数据的交互。此处进行集成的一些潜在点包括使用 Ray 的数据集通用 Arrow 接口与其他工具交互。当数据处于“静止”状态时,Parquet 是与其他工具交互的最佳格式。

使用 Ray 与 CI/CD 工具

在大型团队中工作时,持续集成和交付(CI/CD)是项目有效协作的重要组成部分。使用 Ray 与 CI/CD 的最简单选择是在本地模式下使用 Ray,并将其视为正常的 Python 项目。另外,您可以通过使用 Ray 的作业提交 API 提交测试作业并验证结果。这可以让您测试超出单台计算机规模的 Ray 作业。无论您使用 Ray 的作业 API 还是 Ray 的本地模式,都可以使用 Ray 与任何 CI/CD 工具和虚拟环境。

与 Ray 进行身份验证

Ray的默认部署使您可以轻松入门,因此,在客户端和服务器之间没有任何身份验证。这种缺乏身份验证意味着任何能连接到您的 Ray 服务器的人都可能提交作业并执行任意代码。通常,企业环境需要比默认配置提供的更高级别的访问控制。

Ray 的 gRPC 端点(而不是作业服务器)可以配置为在客户端和服务器之间进行互相认证的传输层安全性(TLS)。Ray 在客户端和头节点之间以及工作节点之间使用相同的 TLS 通信机制。

警告

Ray 的 TLS 实现要求客户端具有私钥。您应该将 Ray 的 TLS 实现视为类似于共享密钥加密,但速度较慢。

另一种选项是使用作业服务器,将端点保持不安全,但限制可以与端点通信的人。这可以通过入口控制器、网络规则甚至作为虚拟私有网络(VPN)的集成部分来完成,例如Tailscale 的 Grafana RBAC 规则示例。幸运的是,Ray 的仪表板——以及作业服务器端点——已绑定到local​host/127.0.0.1,并在 8265 端口上运行。例如,如果你在 Kubernetes 上使用 Traefik 作为入口,你可以像这样通过基本身份验证暴露作业 API:

apiVersion: traefik.containo.us/v1alpha1
kind: Middleware
metadata:
  name: basicauth
  namespace: ray-cluster
spec:
  basicAuth:
    secret: basic-auth
-
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: longhorn-ingress
  namespace: longhorn-system
annotations:
  traefik.ingress.kubernetes.io/router.entrypoints: websecure
  traefik.ingress.kubernetes.io/router.tls.certresolver: le
  traefik.ingress.kubernetes.io/router.tls: "true"
  kubernetes.io/ingress.class: traefik
  traefik.ingress.kubernetes.io/router.middlewares: ba-ray-cluster@kubernetescrd
        spec:
          rules:
            - host: "mymagicendpoints.pigscanfly.ca"
              http:
                paths:
                - pathType: Prefix
                  path: "/"
                  backend:
                    service:
                      name: ray-head-svc
                      port:
                        number: 8265

依赖于限制端点访问的方式存在一个缺点,即任何可以访问该计算机的人都可以向你的集群提交作业,因此对于共享计算资源效果不佳。

Ray 上的多租户

在箱外,Ray 集群支持多个运行作业。当所有作业都来自同一用户且你不关心隔离作业时,你不需要考虑多租户的影响。

在我们看来,Ray 的租户隔离相对于其他部分来说发展不足。Ray 通过将独立的工作节点绑定到作业来实现每个用户的多租户安全,从而降低了不同用户之间意外信息泄露的机会。与 Ray 的执行环境一样,你的用户可以安装不同的 Python 库,但 Ray 不会隔离系统级库(例如 CUDA)。

我们认为 Ray 中的租户隔离就像门上的锁。它的存在是为了保持诚实的人诚实,并防止意外泄露。然而,像命名的演员这样的命名资源可以从任何其他作业中调用。这是命名演员的预期功能,但由于 Ray 经常使用 cloudpickle,你应该考虑任何命名演员都有允许同一集群上的恶意演员执行任意代码的潜力

警告

命名资源会破坏 Ray 的租户隔离。

虽然 Ray 对多租户有一些支持,但我们建议部署多租户 Kubernetes 或 Yarn 集群。多租户很好地引出了为数据源提供凭据的下一个问题。

数据源的凭据

多租户使得数据源的凭据变得复杂,因为你不能依赖基于实例的角色/配置。通过向运行环境添加env_vars,你可以在整个作业中指定凭据。理想情况下,你不应该在源代码中硬编码这些凭据,而是从类似 Kubernetes 秘钥中获取并传播这些值:

ray.init(
                runtime_env={
                    "env_vars": {
                        "AWS_ACCESS_KEY_ID": "key",
                        "AWS_SECRET_ACCESS_KEY": "secret",
                    }
                }
            )

你还可以使用相同的技术为每个函数分配凭据(例如,如果只有一个演员应该具有写权限),通过分配带有.option的运行环境。然而,在实践中,跟踪这些单独的凭据可能会成为一个头疼的问题。

永久与临时集群

部署 Ray 时,您必须选择永久集群还是瞬时集群。对于永久集群,多租户问题和确保自动缩放器能够缩小(例如,没有悬空资源)尤为重要。然而,随着越来越多的企业采用 Kubernetes 或其他云原生技术,我们认为瞬时集群的吸引力将增加。

瞬时集群

瞬时集群有许多好处。最重要的两个好处是低成本和不需要多租户集群。瞬时集群允许在计算结束时完全释放资源。通过提供瞬时集群,您可以避免多租户问题,这可以减少操作负担。瞬时集群使得试验新版本的 Ray 和新的本地库相对轻量化。这也可以防止强制迁移带来的问题,每个团队可以运行自己的 Ray 版本。⁴

瞬时集群在做出选择时也有一些您应该注意的缺点。最明显的两个缺点是需要等待集群启动,以及在应用程序启动时间之上,不能在集群上使用缓存/持久性。启动瞬时集群取决于能够分配计算资源,这取决于您的环境和预算,可能需要从几秒到几天的时间(在云问题期间)。如果您的计算依赖于大量状态或数据,每次在新集群上启动应用程序时,它都会先读取大量信息,这可能会相当慢。

永久集群

除了成本和多租户问题之外,永久集群还带来了额外的缺点。永久集群更容易积累配置“残留物”,当迁移到新集群时可能更难重新创建。随着基础硬件老化,这些集群随时间变得更加脆弱。即使在云中,长时间运行的实例越来越可能遇到故障。永久集群中的长期资源可能最终会包含需要基于监管原因清除的信息。

永久集群还有一些重要的好处,可以很有用。从开发者的角度来看,一个优势是能够拥有长期存在的参与者或其他资源。从运营的角度来看,永久集群不需要同样的启动时间,因此如果需要执行新任务,你不必等待集群变得可用。表 12-1 总结了瞬时和永久集群之间的差异。

表 12-1. 瞬时和永久集群比较表

瞬时/瞬时集群永久集群
资源成本通常较低,除非运行时,工作负载可以进行二进制打包或在用户之间共享资源当资源泄漏阻止自动缩放器缩减时成本较高
库隔离灵活(包括本地)仅在 venv/Conda 环境级别隔离
尝试新版本 Ray 的能力是,可能需要针对新 API 进行代码更改开销较大
最长 actor 生命周期短暂(与集群一起)“永久”(除非集群崩溃/重新部署)
共享 actors
启动新应用程序的时间可能较长(依赖云)可变(如果集群具有几乎即时的备用容量;否则,依赖于云)
数据读取摊销否(每个集群必须读取任何共享数据集)可能(如果结构良好)

使用短暂集群或永久集群的选择取决于您的用例和要求。在某些部署中,短暂集群和永久集群的混合可能提供正确的权衡。

监控

随着您组织中 Ray 集群的规模或数量增长,监控变得越来越重要。Ray 通过其内部仪表板或 Prometheus 提供内置的度量报告,尽管 Prometheus 默认情况下是禁用的。

注意

安装 ray​[default] 时会安装 Ray 的内部仪表板,但仅安装 ray 不会。

当您独自工作或调试生产问题时,Ray 的仪表板非常出色。如果安装了仪表板,Ray 将打印一条包含指向仪表板的链接的信息日志(例如,在 http://127.0.0.1:8265 查看 Ray 仪表板)。此外,ray.init 的结果包含 webui_url,指向度量仪表板。然而,Ray 的仪表板无法创建警报,因此仅在您知道出现问题时才有帮助。Ray 的仪表板 UI 正在 Ray 2 中升级;图 12-1 显示旧版仪表板,而 图 12-2 显示新版仪表板。

spwr 1201

图 12-1. 旧版(2.0 之前)Ray 仪表板

spwr 1202

图 12-2. 新的 Ray 仪表板

如您所见,新仪表板并非自然演化而来;相反,它是经过有意设计的,并包含新信息。两个版本的仪表板均包含有关执行器进程和内存使用情况的信息。新仪表板还具有用于通过 ID 查找对象的 Web UI。

警告

仪表板不应公开,作业 API 使用相同的端口。

Ray 的指标也可以导出到 Prometheus,Ray 默认会选择一个随机端口。您可以通过查看 ray.init 的结果中的 metrics_export_port 来找到端口,或者在启动 Ray 的主节点时指定一个固定的端口 --metrics-export-port=。Ray 与 Prometheus 的集成不仅提供了与 Grafana 等指标可视化工具的集成(见图 12-3),而且在某些参数超出预定范围时添加了警报功能。

spwr 1203

图 12-3. Ray 的示例 Grafana 仪表板⁵

要获取导出的指标,需要配置 Prometheus 来抓取哪些主机或 Pod。对于静态集群的用户,只需提供一个主机文件即可;但对于动态用户,有多种选择。Kubernetes 用户可以使用pod monitors配置 Prometheus 的 Pod 抓取。由于 Ray 集群没有统一的标签适用于所有节点,因此这里我们使用了两个 Pod Monitor——一个用于主节点,一个用于工作节点。

非 Kubernetes 用户可以使用 Prometheus 的file-based discovery,使用 Ray 在主节点自动生成的文件*/tmp/ray/prom_metrics_service​_dis⁠covery.json*。

除了监控 Ray 本身,您还可以在 Ray 内部对代码进行仪表化。您可以将自己的指标添加到 Ray 的 Prometheus 指标中,或者与 OpenTelemetry 集成。正确的指标和仪表化主要取决于您的组织其余部分的使用情况。比较 OpenTelemetry 和 Prometheus 超出了本书的范围。

使用 Ray 指标仪表化您的代码

Ray 的内置指标很好地报告了集群的健康状况,但我们通常关心的是应用程序的健康状况。例如,由于所有作业都处于停滞状态而导致的低内存使用的集群在集群级别看起来很好,但我们实际关心的(为用户提供服务、训练模型等)并没有发生。幸运的是,您可以向 Ray 添加自己的指标来监视应用程序的使用情况。

Tip

您添加到 Ray 指标的指标会像 Ray 的内置指标一样暴露为 Prometheus 指标。

Ray 指标支持在 ray.util.metrics 内的 counter, gaugehistogram 指标类型。这些指标对象不可序列化,因为它们引用了 C 对象。在记录任何值之前,您需要明确创建该指标。在创建新指标时,可以指定名称、描述和标签。一个常用的标签是指标在 actor 内部使用的 actor 名称,用于 actor 分片。由于它们不可序列化,您需要将它们要么创建并在 actors 内使用,如 Example 12-1,要么使用 lazy singleton 模式,如 Example 12-2。

Example 12-1. 在 actor 内部使用 Ray 计数器
# Singleton for reporting a Ray metric

@ray.remote
class MySpecialActor(object):
    def __init__(self, name):
        self.total = 0
        from ray.util.metrics import Counter, Gauge
        self.failed_withdrawls = Counter(
            "failed_withdrawls", description="Number of failed withdrawls.",
            tag_keys=("actor_name",), # Useful if you end up sharding actors
        )
        self.failed_withdrawls.set_default_tags({"actor_name": name})
        self.total_guage = Gauge(
            "money",
            description="How much money we have in total. Goes up and down.",
            tag_keys=("actor_name",), # Useful if you end up sharding actors
        )
        self.total_guage.set_default_tags({"actor_name": name})
        self.accounts = {}

    def deposit(self, account, amount):
        if account not in self.accounts:
            self.accounts[account] = 0
        self.accounts[account] += amount
        self.total += amount
        self.total_guage.set(self.total)

    def withdrawl(self, account, amount):
        if account not in self.accounts:
            self.failed_withdrawls.inc()
            raise Exception("No account")
        if self.accounts[account] < amount:
            self.failed_withdrawls.inc()
            raise Exception("Not enough money")
        self.accounts[account] -= amount
        self.total -= amount
        self.total_guage.set(self.total)
Example 12-2. 使用全局单例方法使 Ray 计数器与远程函数一起使用
# Singleton for reporting a Ray metric

class FailureCounter(object):
    _instance = None

    def __new__(cls):
        if cls._instance is None:
            print('Creating the object')
            cls._instance = super(FailureCounter, cls).__new__(cls)
            from ray.util.metrics import Counter
            cls._instance.counter = Counter(
                "failure",
                description="Number of failures (goes up only).")
        return cls._instance

# This will fail with every zero because divide by zero
@ray.remote
def remote_fun(x):
    try:
        return 10 / x
    except:
        FailureCounter().counter.inc()
        return None

OpenTelemetry 可以在包括 Python 在内的多种语言中使用。Ray 具有基本的 OpenTelemetry 实现,但其使用范围不如其 Prometheus 插件广泛。

使用 Ray 包装自定义程序

Python 的一个强大功能是使用 subprocess 模块⁶ 启动子进程。这些进程可以是系统上的任何 shell 命令或任何应用程序。这种能力允许在 Ray 实现中有许多有趣的选项。我们将在这里展示其中一个选项,即作为 Ray 执行的一部分运行任何自定义 Docker 镜像⁷。Example 12-3 演示了如何实现这一点。

Example 12-3. 在 Ray 远程函数中执行 Docker 镜像
ray.init(address='ray://<*`your IP`*>:10001')

@ray.remote(num_cpus=6)
def runDocker(cmd):
   with open("result.txt", "w") as output:
       result = subprocess.run(
           cmd,
           shell=True,  # Pass single string to shell, let it handle.
           stdout=output,
           stderr=output
       )

   print(f"return code {result.returncode}")
   with open("result.txt", "r") as output:
       log = output.read()
   return log

cmd='docker run --rm busybox echo "Hello world"'

result=runDocker.remote(cmd)
print(f"result: {ray.get(result)}")

此代码包含一个简单的远程函数,执行外部命令并返回执行结果。主函数向其传递一个简单的 docker run 命令,然后打印调用结果。

此方法允许您在 Ray 远程函数执行的一部分中执行任何现有的 Docker 镜像,这反过来允许多语言 Ray 实现,甚至执行具有特定库需求的 Python 需要为此远程函数运行创建虚拟环境。它还允许在 Ray 执行中轻松包含预构建的镜像。

在 Ray 内部使用 subprocess 运行 Docker 镜像只是其有用应用之一。一般来说,可以通过这种方法调用安装在 Ray 节点上的任何应用程序。

结论

尽管 Ray 最初是在研究实验室中创建的,您可以通过这里描述的实现增强将 Ray 引入主流企业计算基础设施。具体来说,请确保执行以下操作:

  • 仔细评估此操作可能带来的安全性和多租户问题。

  • 要注意与 CI/CD 和可观察性工具的集成。

  • 决定是否需要永久或短暂的 Ray 集群。

这些考虑因您的企业环境和 Ray 的具体用例而异。

到达本书的这一点,您应该对所有 Ray 基础知识有了扎实的掌握,并了解下一步的指引。我们期待您加入 Ray 社区,并鼓励您查看社区资源,包括Ray 的 Slack 频道。如果您想看看如何将 Ray 的各个部分组合起来,附录 A 探讨了如何为开源卫星通信系统构建后端的一种方式。

¹ 一些常见的安全扫描工具包括 Grype、Anchore 和 Dagda。

² 使其与 gRPC 客户端配合工作更复杂,因为 Ray 的工作节点需要能够与头节点和 Redis 服务器通信,这在使用本地主机进行绑定时会出现问题。

³ 本书作者中有些人有在 Tailscale 工作的朋友,其他解决方案也完全可以。

⁴ 在实际操作中,我们建议仅支持少数几个版本的 Ray,因为它在快速发展。

⁵ 查看Ray metrics-1650932823424.json获取配置信息。

⁶ 特别感谢 Michael Behrendt 建议本节讨论的实现方法。

⁷ 这仅适用于使用 VM 上的 Ray 安装的云安装环境。参考附录 B 了解如何在 IBM Cloud 和 AWS 上执行此操作。

附录 A. Space Beaver 案例研究:Actors、Kubernetes 等

Space Beaver 项目(来自 Pigs Can Fly Labs)利用 Swarm 和简单邮件传输协议(SMTP)提供被礼貌称为性价比高(即便宜)的离网消息服务。¹ Space Beaver 核心架构的初稿使用了 Scala 和 Akka,但后来我们转而使用 Ray。通过使用 Python 的 Ray 而不是 Scala 的 Akka,我们能够重用网站的对象关系映射(ORM)并简化部署。

虽然在 Kubernetes 上部署 Akka 应用是可能的,但(依据 Holden 的意见)相比使用 Ray 完成相同任务要复杂得多。² 在本附录中,我们将概述 Space Beaver 后端的一般设计,各种 actor 的代码,并展示如何部署它(以及类似的应用)。

注意

您可以在 Pigs Can Fly Labs GitHub 仓库 找到此案例研究的代码。

高级设计

Space Beaver 的核心要求是作为电子邮件(通过 SMTP)、短信(通过 Twilio)和 Swarm 卫星 API 之间的桥梁。其中大部分涉及一定程度的状态,例如运行 SMTP 服务器,但出站邮件消息可以在没有任何状态的情况下实现。图 A-1 展示了设计的大致轮廓。

spwr aa01

图 A-1. Actor 布局

实施

现在您已经看到了一个大致的设计,是时候探索您在整本书中学到的模式是如何应用来将所有内容整合在一起的了。

出站邮件客户端

出站邮件客户端是唯一一个无状态的代码,因为它为每个出站消息建立连接。由于它是无状态的,我们将其实现为常规的远程函数,每个传入请求创建一个。根据传入请求的数量,Ray 可以根据需要扩展或缩减远程函数实例的数量。由于客户端可能在外部主机上阻塞,因此能够扩展包含邮件客户端的远程函数实例的数量非常有用。

提示

调度每个远程函数调用都需要一些开销。在我们的情况下,预期的消息速率并不高。如果您对所需并发有很好的了解,应考虑使用 Ray 的 multiprocessing.Pool 来避免函数创建开销。

但是,我们希望序列化某些设置,比如在设置类中,所以我们用一个特殊的方法包装出站邮件客户端函数,通过自引用传递,尽管它不是一个 actor,如 示例 A-1 所示。

示例 A-1. 邮件客户端
class MailClient(object):
    """
 Mail Client
 """

    def __init__(self, settings: Settings):
        self.settings = settings

    def send_message(self, *args, **kwargs):
        """
 Wrap send_msg to include settings.
 """
        return self.send_msg.remote(self, *args, **kwargs)

    @ray.remote(retry_exceptions=True)
    def send_msg(self, msg_from: str, msg_to: str, data: str):
        message = MIMEMultipart("alternative")
        message["From"] = msg_from
        message["To"] = msg_to
        message["Subject"] = f"A satelite msg: f{data[0:20]}"
        part1 = MIMEText(data, "plain")
        # Possible later: HTML
        message.attach(part1)

        with SMTP(self.settings.mail_server, port=self.settings.mail_port) as smtp:
            if self.settings.mail_username is not None:
                smtp.login(self.settings.mail_username,
                           self.settings.mail_password)
            logging.info(f"Sending message {message}")
            r = smtp.sendmail(
                msg=str(message),
                from_addr=msg_from,
                to_addrs=msg_to)
            return r

另一个合理的方法是使其有状态,并跨消息维持连接。

共享的 actor 模式和工具

系统的其余组件在长期网络连接或数据库连接的上下文中都是有状态的。 由于用户参与者需要与系统中运行的所有其他参与者进行通信(反之亦然),为了简化发现其他运行中参与者的过程,我们添加了一个LazyNamedActorPool,它结合了命名参与者和参与者池的概念(示例 A-2)。³

示例 A-2. 懒加载命名参与者池
class LazyNamedPool:
    """
 Lazily constructed pool by name.
 """

    def __init__(self, name, size, min_size=1):
        self._actors = []
        self.name = name
        self.size = size
        self.min_actors = min_size

    def _get_actor(self, idx):
        actor_name = f"{self.name}_{idx}"
        try:
            return [ray.get_actor(actor_name)]
        except Exception as e:
            print(f"Failed to fetch {actor_name}: {e} ({type(e)})")
            return []

    def _get_actors(self):
        """
 Get actors by name, caches result once we have the "full" set.
 """
        if len(self._actors) < self.size:
            return list(flat_map(self._get_actor, range(0, self.size)))

    def get_pool(self):
        new_actors = self._get_actors()
        # Wait for at least min_actors to show up
        c = 0
        while len(new_actors) < self.min_actors and c < 10:
            print(f"Have {new_actors} waiting for {self.min_actors}")
            time.sleep(2)
            new_actors = self._get_actors()
            c = c + 1
        # If we got more actors
        if (len(new_actors) > len(self._actors)):
            self._actors = new_actors
            self._pool = ActorPool(new_actors)
        if len(new_actors) < self.min_actors:
            raise Exception("Could not find enough actors to launch pool.")
        return self._pool

我们使用的另一个共享模式是优雅关闭,在此模式下,我们要求参与者停止处理新消息。 一旦参与者停止接受新消息,队列中的现有消息将被排出,根据需要发送到卫星网络或 SMTP 网络。 然后可以删除参与者,而不必持久化和恢复参与者正在处理的消息。 我们将在接下来看到的邮件服务器中实现此模式,如示例 A-3 所示。

示例 A-3. 停止进行升级
    async def prepare_for_shutdown(self):
        """
 Prepare for shutdown, so stop remove pod label (if present) 
 then stop accepting connections.
 """
        if self.label is not None:
            try:
                self.update_label(opp="remove")
                await asyncio.sleep(120)
            except Exception:
                pass
        self.server.stop()

邮件服务器参与者

邮件服务器参与者负责接受新的入站消息并将其传递给用户参与者。 这是作为 aiosmtpd 服务器处理程序实现的,如示例 A-4 所示。

示例 A-4. 邮件服务器消息处理
    async def handle_RCPT(self, server, session, envelope, address, rcpt_options):
        """
 Call back for RCPT. This only accepts email for us, no relaying.
 """
        logging.info(f"RCPT to with {address} received.")
        if not address.endswith(f"@{self.domain}"):
            self.emails_rejected.inc()
            return '550 not relaying to that domain'
        # Do we really want to support multiple emails? idk.
        envelope.rcpt_tos.append(address)
        return '250 OK'

    async def handle_DATA(self, server, session, envelope):
        """
 Call back for the message data.
 """
        logging.info(f"Received message {envelope}")
        print('Message for %s' % envelope.rcpt_tos)
        parsed_email = message_from_bytes(envelope.content, policy=policy.SMTPUTF8)
        text = ""
        if "subject" in parsed_email:
            subject = parsed_email["subject"]
            text = f"{subject}\n"
        body = None
        # You would think "get_body" would give us the body but...maybe not? ugh
        try:
            body = (parsed_email.get_body(preferencelist=('plain', 'html',)).
                    get_content())
        except Exception:
            if parsed_email.is_multipart():
                for part in parsed_email.walk():
                    ctype = part.get_content_type()
                    cdispo = str(part.get('Content-Disposition'))

                    # skip any text/plain (txt) attachments
                    if ctype == 'text/plain' and 'attachment' not in cdispo:
                        body = part.get_payload(decode=True)  # decode
                        break
                    # not multipart - i.e. plain text, no attachments, 
                    # keeping fingers crossed
            else:
                body = parsed_email.get_payload(decode=True)
        text = f"{text}{body}"
        text = text.replace("\r\n", "\n").rstrip("\n")
        self.emails_forwaded.inc()
        for rcpt in envelope.rcpt_tos:
            message = CombinedMessage(
                text=text,
                to=parseaddr(rcpt)[1].split('@')[0],
                msg_from=envelope.mail_from,
                from_device=False,
                protocol=EMAIL_PROTOCOL)
            self.user_pool.get_pool().submit(
                lambda actor, message: actor.handle_message.remote(message),
                message)
        return '250 Message accepted for delivery'

拥有邮件服务器的一个重要部分是外部用户可以连接到服务器。 对于 HTTP 服务(如推理服务器),您可以使用 Ray Serve 公开您的服务。 但是,邮件服务器使用 SMTP,目前无法使用 Ray Serve 公开。 因此,为了允许 Kubernetes 将请求路由到正确的主机,邮件参与者会像示例 A-5 中所示那样标记自身。

示例 A-5. 邮件服务器 Kubernetes 标记
    def update_label(self, opp="add"):
        label = self.label
        patch_json = (
            "[{" +
            f""" "op": "{opp}", "path": "/metadata/labels/{label}", """ + 
            f""" "value": "present" """ +
            "}]")
        print(f"Preparing to patch with {patch_json}")
        try:
            kube_host = os.getenv("KUBERNETES_SERVICE_HOST")
            kube_port = os.getenv("KUBERNETES_PORT_443_TCP_PORT", "443")
            pod_namespace = os.getenv("POD_NAMESPACE")
            pod_name = os.getenv("POD_NAME")
            url = f"http://{kube_host}:{kube_port}/api/v1/namespace/" + 
                  f"{pod_namespace}/pods/{pod_name}"
            headers = {"Content-Type": "application/json-patch+json"}
            print(f"Patching with url {url}")
            result = requests.post(url, data=patch_json, headers=headers)
            logging.info(f"Got back {result} updating header.")
            print(f"Got patch result {result}")
            if result.status_code != 200:
                raise Exception(f"Got back a bad status code {result.status_code}")
        except Exception as e:
            print(f"Got an error trying to patch with https API {e}")
            patch_cmd = [
                "kubectl",
                "patch",
                "pod",
                "-n",
                pod_namespace,
                pod_name,
                "--type=json",
                f"-p={patch_json}"]
            print("Running cmd:")
            print(" ".join(patch_cmd))
            out = subprocess.check_output(patch_cmd)
            print(f"Got {out} from patching pod.")
        print("Pod patched?")

卫星参与者

卫星参与者类似于邮件服务器参与者,但不是接受入站请求,而是通过轮询获取新消息,并且我们也通过它发送消息。 轮询就像在车上开着一个六岁的孩子一样,不停地问:“我们到了吗?” 但在我们的情况下,问题是“你有没有新消息?” 在 Ray 中,异步参与者是实现轮询的最佳选项,因为轮询循环永远运行,但您仍然希望能够处理其他消息。 示例 A-6 展示了卫星参与者的轮询实现。

示例 A-6. 卫星参与者轮询
    async def run(self):
        print("Prepairing to run.")
        internal_retries = 0
        self.running = True
        while self.running:
            try:
                self._login()
                while True:
                    await asyncio.sleep(self.delay)
                    await self.check_msgs()
                    internal_retries = 0  # On success reset retry counter.
            except Exception as e:
                print(f"Error {e} while checking messages.")
                logging.error(f"Error {e}, retrying")
                internal_retries = internal_retries + 1
                if (internal_retries > self.max_internal_retries):
                    raise e

此轮询循环大部分逻辑委托给check_msgs,如示例 A-7 所示。

示例 A-7. 卫星检查消息
    async def check_msgs(self):
        print("Checking messages...")
        res = self.session.get(
            self._getMessageURL,
            headers=self.hdrs,
            params={'count': self._page_request_size, 'status': 0})
        messages = res.json()
        for item in messages:
            # Is this a message we are responsible for
            if int(item["messageId"]) % self.poolsize == self.idx:
                try:
                    await self._process_mesage(item)
                except Exception as e:
                    logging.error(f"Error {e} processing {item}")
                self.session.post(
                    self._ackMessageURL.format(item['packetId']),
                    headers=self.hdrs)
        print("Done!")

在卫星参与者中我们使用的另一个有趣模式是在测试中暴露可序列化的结果,但在正常流程中保持数据以更高效的异步表示。 这种模式在消息解码方式中展示,如示例 A-8 所示。

示例 A-8. 卫星处理消息
    async def _decode_message(self, item: dict) -> AsyncIterator[CombinedMessage]:
        """
 Decode a message. Note: result is not serializable.
 """
        raw_msg_data = item["data"]
        logging.info(f"msg: {raw_msg_data}")
        messagedata = MessageDataPB()  # noqa
        bin_data = base64.b64decode(raw_msg_data)
        # Note: this really does no validation, so if it gets a message instead
        # of MessageDataPb it just gives back nothing
        messagedata.ParseFromString(bin_data)
        logging.info(f"Formatted: {text_format.MessageToString(messagedata)}")
        if (len(messagedata.message) < 1):
            logging.warn(f"Received {raw_msg_data} with no messages?")
        for message in messagedata.message:
            yield CombinedMessage(
                text=message.text, to=message.to, protocol=message.protocol,
                msg_from=item["deviceId"], from_device=True
            )

    async def _ser_decode_message(self, item: dict) -> List[CombinedMessage]:
        """
 Decode a message. Serializeable but blocking. Exposed for testing.
 """
        gen = self._decode_message(item)
        # See PEP-0530
        return [i async for i in gen]

    async def _process_message(self, item: dict):
        messages = self._decode_message(item)
        async for message in messages:
            self.user_pool.get_pool().submit(
                lambda actor, msg: actor.handle_message.remote(msg),
                message)

用户演员

虽然其他演员都是异步的,允许在演员内部进行并行处理,但用户演员是同步的,因为 ORM 尚未处理异步执行。用户演员的代码在 示例 A-9 中展示得相当完整,因此你可以看到共享的模式(其他演员因简洁起见而跳过)。

示例 A-9. 用户演员
class UserActorBase():
    """
 Base client class for talking to the swarm.space APIs.
 Note: this actor is not async because Django's ORM is not happy with
 async.
 """

    def __init__(self, settings: Settings, idx: int, poolsize: int):
        print(f"Running on {platform.machine()}")
        self.settings = settings
        self.idx = idx
        self.poolsize = poolsize
        self.satellite_pool = utils.LazyNamedPool("satellite", poolsize)
        self.outbound_sms = utils.LazyNamedPool("sms", poolsize)
        self.mail_client = MailClient(self.settings)
        self.messages_forwarded = Counter(
            "messages_forwarded",
            description="Messages forwarded",
            tag_keys=("idx",),
        )
        self.messages_forwarded.set_default_tags(
            {"idx": str(idx)})
        self.messages_rejected = Counter(
            "messages_rejected",
            description="Rejected messages",
            tag_keys=("idx",),
        )
        self.messages_rejected.set_default_tags(
            {"idx": str(idx)})
        print(f"Starting user actor {idx}")

    def _fetch_user(self, msg: CombinedMessage) -> User:
        """
 Find the user associated with the message.
 """
        if (msg.from_device):
            device = Device.objects.get(serial_number=msg.msg_from)
            return device.user
        elif (msg.protocol == EMAIL_PROTOCOL):
            username = msg.to
            print(f"Fetching user {msg.to}")
            try:
                return User.objects.get(username=username)
            except Exception as e:
                print(f"Failed to get user: {username}?")
                raise e
        elif (msg.protocol == SMS_PROTOCOL):
            print(f"Looking up user for phone {msg.to}")
            try:
                return User.objects.get(twillion_number=str(msg.to))
            except Exception as e:
                print(f"Failed to get user: {username}?")
                raise e
        else:
            raise Exception(f"Unhandled protocol? - {msg.protocol}")

    def prepare_for_shutdown(self):
        """
 Prepare for shutdown (not needed for sync DB connection)
 """
        pass

    def handle_message(self, input_msg: CombinedMessage):
        """
 Handle messages.
 """
        print(f"Handling message {input_msg}")
        user = self._fetch_user(input_msg)
        self.messages_forwarded.inc()
        if (input_msg.from_device):
            msg = {
                "data": input_msg.text,
                "msg_from": f"{user.username}@spacebeaver.com",
                "msg_to": input_msg.to
            }
            # Underneath this calls a ray.remote method.
            self.mail_client.send_message(**msg)
        else:
            msg = {
                "protocol": input_msg.protocol,
                "msg_from": input_msg.msg_from,
                "msg_to": user.device.serial_number,
                "data": input_msg.text
            }
            self.satellite_pool.get_pool().submit(
                lambda actor, msg: actor.send_message.remote(**msg),
                msg)

@ray.remote(max_restarts=-1)
class UserActor(UserActorBase):
    """
 Routes messages and checks the user account info.
 """
注释

Django 是一个流行的 Python Web 开发框架,包括许多组件,包括我们正在使用的 ORM。

SMS 演员和 Serve 实现

除了卫星和电子邮件网关的演员外,Space Beaver 还使用 Ray Serve 来公开 phone-api,如 示例 A-10 所示。

示例 A-10. 使用 Ray Serve 处理入站短信
from messaging.utils import utils
from pydantic import BaseModel, Field
from fastapi import FastAPI, HTTPException, Request
from ray import serve
from messaging.settings.settings import Settings
from messaging.proto.MessageDataPB_pb2 import SMS as SMS_PROTOCOL
from messaging.internal_types import CombinedMessage
from typing import Optional
from twilio.request_validator import RequestValidator

# 1: Define a FastAPI app and wrap it in a deployment with a route handler.
app = FastAPI()

class InboundMessage(BaseModel):
    x_twilio_signature: str
    message_from: str = Field(None, alias='from')
    to: str
    body: str
    msg_type: Optional[str] = Field(None, alias="type")

@serve.deployment(num_replicas=3, route_prefix="/")
@serve.ingress(app)
class PhoneWeb:
    def __init__(self, settings: Settings, poolsize: int):
        self.settings = settings
        self.poolsize = poolsize
        self.user_pool = utils.LazyNamedPool("user", poolsize)
        self.validator = RequestValidator(settings.TW_AUTH_TOKEN)

    # FastAPI will automatically parse the HTTP request for us.
    @app.get("/sms")
    async def inbound_message(self, request: Request, 
    message: InboundMessage) -> str:
        # Validate the message
        request_valid = self.validator.validate(
            request.url,
            request.form,
            request.headers.get('X-TWILIO-SIGNATURE', ''))
        if request_valid:
            internal_message = CombinedMessage(
                text=message.body, to=message.to, protocol=SMS_PROTOCOL,
                msg_from=message.message_from, from_device=False
            )
            self.user_pool.get_pool().submit(
                lambda actor, msg: actor.handle_message.remote(msg), 
                internal_message)
            return ""
        else:
            raise HTTPException(status_code=403, detail="Validation failed.")

测试

为了方便测试,演员代码被分解为一个基类,然后扩展为演员类。这允许独立测试邮件服务器,而不依赖其在 Ray 上的部署,如 示例 A-11 中所示。

示例 A-11. 独立邮件测试
class StandaloneMailServerActorTests(unittest.TestCase):
    port = 7779 + 100 * random.randint(0, 9)

    def setUp(self):
        self.port = self.port + 1
        self.actor = mailserver_actor.MailServerActorBase(
            idx=1, poolsize=1, port=self.port, hostname="0.0.0.0",
            label=None)
        self.actor.user_pool = test_utils.FakeLazyNamedPool("u", 1)
        self.pool = self.actor.user_pool.get_pool()

    def tearDown(self):
        self.actor.server.stop()
        self.server = None

    def test_constructor_makes_server(self):
        self.assertEquals(self.actor.server.hostname, "0.0.0.0")

    def test_extract_body_and_connect(self):
        client = Client("localhost", self.port)
        msg_text = "Hi Boop, this is timbit."
        client.sendmail("c@gull.com", "boop@spacebeaver.com",
                        msg_text)
        self.assertEquals(self.pool.submitted[0][1].text, msg_text)
        self.assertEquals(self.pool.submitted[0][1].protocol, EMAIL_PROTOCOL)
        self.assertEquals(self.pool.submitted[0][1].from_device, False)

尽管这些独立测试可以减少开销,但最好还是有一些完整的演员测试。你可以通过在测试中重复使用 Ray 上下文来加快速度(尽管当出现问题时,调试是很痛苦的),就像在 示例 A-12 中展示的那样。

示例 A-12. 完整演员测试
@ray.remote
class MailServerActorForTesting(mailserver_actor.MailServerActorBase):
    def __init__(self, idx, poolsize, port, hostname):
        mailserver_actor.MailServerActorBase.__init__(self, idx, poolsize, 
                                                      port, hostname)
        self.user_pool = test_utils.FakeLazyNamedPool("user", 1)

class MailServerActorTestCases(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        ray.init()

    @classmethod
    def tearDownClass(cls):
        ray.shutdown()

    def test_mail_server_actor_construct(self):
        mailserver_actor.MailServerActor.remote(0, 1, 7587, "localhost")

部署

尽管 Ray 处理了大部分部署工作,我们仍然需要创建一个 Kubernetes 服务来使我们的 SMTP 和 SMS 服务可访问。在我们的测试集群上,我们通过暴露一个负载均衡器服务来实现,如 示例 A-13 所示。

示例 A-13. SMTP 和 SMS 服务
apiVersion: v1
kind: Service
metadata:
  name: message-backend-svc
  namespace: spacebeaver
spec:
  selector:
    mail_ingress: present
  ports:
    - name: smtp
      protocol: TCP
      port: 25
      targetPort: 7420
  type: LoadBalancer
  loadBalancerIP: 23.177.16.210
  sessionAffinity: None
---
apiVersion: v1
kind: Service
metadata:
  name: phone-api-svc
  namespace: spacebeaver
spec:
  selector:
    ray-cluster-name: spacebeaver
  ports:
    - name: http
      protocol: TCP
      port: 80
      targetPort: 8000
  type: LoadBalancer
  sessionAffinity: None
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: spacebeaver-phone-api-ingress
  namespace: spacebeaver
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt
    cert-manager.io/issue-temporary-certificate: "true"
    acme.cert-manager.io/http01-edit-in-place: "true"
spec:
  ingressClassName: nginx
  tls:
  - hosts:
      - phone-api.spacebeaver.com
    secretName: phone-api-tls-secret
  rules:
    - host: "phone-api.spacebeaver.com"
      http:
        paths:
        - pathType: Prefix
          path: "/"
          backend:
            service:
              name: phone-api-svc
              port:
                number: 80

如图所示,SMTP 和 SMS 服务使用不同的节点选择器将请求路由到正确的 pod。

结论

Space Beaver 消息后端的 Ray 移植大大减少了部署和打包的复杂性,同时增加了代码复用。部分原因来自于广泛的 Python 生态系统(流行的前端工具和后端工具),但其余部分来自于 Ray 的无服务器特性。与之相对应的 Akka 系统需要用户在调度演员时考虑意图,而使用 Ray,我们可以把这些交给调度器。当然,Akka 带来了许多好处,比如强大的 JVM 生态系统,但希望这个案例研究已经展示了你可以如何有趣地使用 Ray。

¹ Holden Karau 是 Pigs Can Fly Labs 的管理合伙人,虽然她真的希望你会购买这款离线消息设备,但她意识到阅读编程书籍的人群和需要低成本开源卫星电子邮件消息的人群之间的交集相当小。实际上,对于许多消费者使用案例来说,Garmin inReach Mini2 或者 Apple 可能更好。

² 在 Akka on Kubernetes 中,用户需要手动将 actors 调度到单独的容器上并重新启动 actors,而 Ray 可以为我们处理这些。

³ 另一种解决方案是让主程序或启动程序在创建 actors 时通过引用来调用它们。