Python Dask 扩展指南(早期发布)(二)
原文:
annas-archive.org/md5/51ecaf36908acb7901fbeb7d885469d8
译者:飞龙
第七章:使用 Dask Actors 添加可变状态
Dask 主要专注于扩展分析用例,但您也可以将其用于扩展许多其他类型的问题。到目前为止,在 Dask 中使用的大多数工具都是函数式的。函数式编程意味着先前的调用不会影响未来的调用。在像 Dask 这样的分布式系统中,无状态函数是常见的,因为它们可以在失败时安全地多次重新执行。在训练过程中更新模型的权重是数据科学中常见的状态示例。在分布式系统中处理状态的最常见方法之一是使用演员模型。本章将介绍通用的演员模型及其在 Dask 中的具体实现。
Dask futures 提供了一个非可变的分布式状态,其中的值存储在工作节点上。然而,对于想要更新状态的情况(比如更改银行账户余额,一个替代方案在 示例 7-1 中有所说明),这种方法并不适用,或者在训练过程中更新机器学习模型权重。
提示
Dask 演员有许多限制,我们认为在许多情况下正确的答案是将可变状态保持在 Dask 之外(比如在数据库中)。
当然,您不必使用分布式可变状态。在某些情况下,您可能选择不使用分布式状态,而是将其全部放入主程序中。这可能会迅速导致负责主程序的节点成为瓶颈。其他选择包括将状态存储在 Dask 之外,比如数据库,这有其自身的权衡。虽然本章重点介绍如何使用演员模型,但我们最后会讨论何时不使用 Dask 演员以及处理状态的替代方法,这同样重要。
提示
Dask 还有分布式可变对象,详见 “用于调度的分布式数据结构”。
什么是演员模型?
在演员模型中,演员们会做以下事情:
-
存储数据
-
接收并响应消息,包括来自其他参与者和外部
-
传递消息
-
创建新的演员
演员模型是处理并行和分布式系统中状态的一种技术,避免了锁定。虽然适当的锁定可以确保只有一个代码片段修改给定值,但这可能非常昂贵且难以正确实现。锁定的常见问题称为死锁,这是资源按错误顺序获取/释放,导致程序可能永远阻塞。在分布式系统中,锁定的缓慢和困难只会增加。¹ 演员模型于 1973 年引入,此后已在大多数编程语言中实现。² 一些流行的现代实现包括 Scala 中的 Akka 和 .NET 语言中的实现。
每个 actor 可以被看作是一个持有其状态注释的人,而且只允许该人读取或更新注释。当代码的另一部分想要访问或修改状态时,必须要求 actor 这样做。
从概念上讲,这与面向对象编程中的类非常相似。然而,与通用类不同的是,actors 一次只处理一个请求,以确保 actor 的状态一致性。为了提高吞吐量,人们通常会创建一个 actor 池(假设可以对 actor 的状态进行分片或复制)。我们将在下一节中介绍一个示例。
Actor 模型非常适合许多分布式系统场景。以下是一些典型的使用案例,其中 actor 模型可能具有优势:
-
您需要处理一个大型分布式状态,在调用之间很难同步(例如,ML 模型权重,计数器等)。
-
您希望使用不需要来自外部组件显著交互的单线程对象。这对于不完全理解的遗留代码尤其有用。³
现在您对 actor 模型有了一般的了解,是时候学习 Dask 如何实现它以及其中的权衡了。
Dask Actors
Dask actors 是 actors 的一种实现方式,其属性与 Dask 和其他系统之间有所不同。与 Dask 的其余部分不同,Dask actors 不具有容错能力。如果运行 actor 的节点或进程失败,则 actor 内部的数据将丢失,Dask 无法恢复。
您的第一个 actor(这是一个银行账户)
在 Dask 中创建一个 actor 相对简单。首先,您创建一个普通的 Python 类,其中包含您将调用的函数。这些函数负责在 actor 模型中接收和响应消息。一旦您有了类,您将其 submit
给 Dask,同时带有标志 actor=True
,Dask 将返回一个表示 actor 引用的 future。当您获取此 future 的 result
时,Dask 创建并返回给您一个代理对象,该对象将任何函数调用作为消息传递给 actor。
注意
请注意,这实际上是一个面向对象的银行账户实现,但我们没有任何锁,因为我们只有一个单线程改变值。
让我们看看如何为银行账户实现一个常见的 actor。在 Example 7-1 中,我们定义了三个方法——balance
、deposit
和 withdrawal
——用于与 actor 交互。一旦定义了 actor,我们请求 Dask 调度该 actor,以便我们可以调用它。
示例 7-1. 制作一个银行账户 actor
class BankAccount:
""" A bank account actor (similar to counter but with + and -)"""
# 42 is a good start
def __init__(self, balance=42.0):
self._balance = balance
def deposit(self, amount):
if amount < 0:
raise Exception("Cannot deposit negative amount")
self._balance += amount
return self._balance
def withdrawal(self, amount):
if amount > self._balance:
raise Exception("Please deposit more money first.")
self._balance -= amount
return self._balance
def balance(self):
return self._balance
# Create a BankAccount on a worker
account_future = client.submit(BankAccount, actor=True)
account = account_future.result()
当您在生成的代理对象上调用方法时(参见示例 7-2),Dask 将调度远程过程调用并立即返回一个特殊的 ActorFuture。这使您可以以非阻塞方式使用 actors。与通用的@dask.delayed
调用不同,这些调用都被路由到同一个进程,即 Dask 安排 actor 的进程。
示例 7-2. 使用银行账户 actor
# Non-blocking
balance_future = account.balance()
# Blocks
balance = balance_future.result()
try:
f = account.withdrawal(100)
f.result() # throws an exception
except Exception as e:
print(e)
ActorFuture 不可序列化,因此如果需要传输调用 actor 的结果,需要阻塞并获取其值,如示例 7-3 所示。
示例 7-3. ActorFutures 不可序列化
def inc(x):
import time
time.sleep(x)
f = counter.add(x)
# Note: the actor (in this case `counter`) is serializable;
# however, the future we get back from it is not.
# This is likely because the future contains a network connection
# to the actor, so need to get its concrete value here. If we don't
# need the value, you can avoid blocking and it will still execute.
return f.result()
每个银行账户一个 actor 可以很好地避免瓶颈,因为每个银行账户可能不会有太多排队的交易,但这样做稍微低效,因为存在非零的 actor 开销。一个解决方案是通过使用键和哈希映射来扩展我们的银行账户 actor,以支持多个账户,但如果所有账户都在一个 actor 内部,这可能会导致扩展问题。
缩放 Dask Actors
本章早期描述的 actor 模型通常假定 actors 是轻量级的,即它们包含单个状态片段,并且不需要扩展/并行化。在 Dask 和类似系统(包括 Akka)中,actors 通常用于更粗粒度的实现,并且可能需要扩展。⁴
与dask.delayed
类似,您可以通过创建多个 actor 水平(跨进程/机器)或垂直(使用更多资源)来扩展 actors。然而,横向扩展 actors 并不像只需增加更多机器或工作器那样简单,因为 Dask 无法将单个 actor 分割为多个进程。
在横向扩展 actors 时,您需要以一种可以使多个 actors 处理其状态的方式来分割状态。一种技术是使用actor 池(见图 7-1)。这些池可以具有静态映射,例如用户→actor,或者在 actors 共享数据库的情况下,可以使用轮询或其他非确定性的负载均衡。
图 7-1. 使用一致性哈希的扩展 actor 模型
我们将银行账户示例扩展到一个“银行”,其中一个 actor 可能负责多个账户(但不是银行中所有账户)。然后,我们可以使用带哈希的 actor 池将请求路由到正确的“分支”或 actor,如示例 7-4 所示。
示例 7-4. 用于银行的哈希 actor 池示例扩展
class SketchyBank:
""" A sketchy bank (handles multiple accounts in one actor)."""
# 42 is a good start
def __init__(self, accounts={}):
self._accounts = accounts
def create_account(self, key):
if key in self._accounts:
raise Exception(f"{key} is already an account.")
self._accounts[key] = 0.0
def deposit(self, key, amount):
if amount < 0:
raise Exception("Cannot deposit negative amount")
if key not in self._accounts:
raise Exception(f"Could not find account {key}")
self._accounts[key] += amount
return self._accounts[key]
def withdrawal(self, key, amount):
if key not in self._accounts:
raise Exception(f"Could not find account {key}")
if amount > self._accounts[key]:
raise Exception("Please deposit more money first.")
self._accounts[key] -= amount
return self._accounts[key]
def balance(self, key):
if key not in self._accounts:
raise Exception(f"Could not find account {key}")
return self._accounts[key]
class HashActorPool:
"""A basic deterministic actor pool."""
def __init__(self, actorClass, num):
self._num = num
# Make the request number of actors
self._actors = list(
map(lambda x: client.submit(SketchyBank, actor=True).result(),
range(0, num)))
def actor_for_key(self, key):
return self._actors[hash(key) % self._num]
holdens_questionable_bank = HashActorPool(SketchyBank, 10)
holdens_questionable_bank.actor_for_key("timbit").create_account("timbit")
holdens_questionable_bank.actor_for_key(
"timbit").deposit("timbit", 42.0).result()
限制
如前所述,Dask actors 在机器或进程失败时不具备韧性。这是 Dask 的设计决策,并非所有 actor 系统都是如此。许多 actor 系统提供了不同的选项,用于在失败时持久化和恢复 actors。例如,Ray 具有可恢复 actors 的概念(在工作流内部自动管理或手动管理)。
警告
对 dask.delayed
函数的调用在失败时可以重试,如果它们调用 actors 上的函数,则这些函数调用将被复制。如果不能重新执行函数,则需要确保仅从其他 actors 内部调用它。
Dask 的 actor 模型不如 Ray 的 actor 模型功能完善,就像 Ray 的 DataFrame 不如 Dask 的一样。您可能希望考虑在 Ray 上运行 Dask,以获得两者的最佳结合。虽然 Holden 有所偏见,但她建议您如果对 Ray 感兴趣,可以查看她的书 Scaling Python with Ray。
何时使用 Dask Actors
行业中的一个常见问题是没有意识到我们很酷的新工具并不一定适合当前的工作。正如俗话说,“拿着锤子,眼前的都是钉子。” 如果你不需要改变状态,应该坚持使用任务而不是 actors。 请记住,处理状态还有其他选择,如在 Table 7-1 中所示。
Table 7-1. 可变状态管理技术比较
本地状态(例如驱动程序) | Dask actors | 外部分布式状态(例如 ZooKeeper、Ray 或 AKKA) | |
---|---|---|---|
可扩展性 | 否,所有状态必须适合单台机器。 | 每个 actor 内的状态必须适合一台机器,但 actors 是分布的。 | 是^(a) |
韧性 | 中等,但没有增加韧性成本(例如,驱动程序的丢失已经是灾难性的)。 | 不,任何 worker 的丢失对 actor 都是灾难性的。 | 是,整个集群的丢失可以恢复。 |
性能开销 | RPC 到驱动程序 | 与 dask.delayed 相同 | RPC 到外部系统 + 外部系统开销 |
代码复杂性 | 低 | 中等 | 高(需要学习和集成的新库),避免重复执行的额外逻辑 |
部署复杂性 | 低 | 低 | 高(需要维护的新系统) |
^(a) Ray actors 仍然要求 actor 内的状态必须适合单台机器。Ray 还有其他工具用于分片或创建 actors 池。 |
和生活中的大多数事物一样,选择正确的技术是一种特定问题的妥协。我们认为,在处理需要可变状态的大多数情况下,其中一种本地(例如,驱动程序)状态,或者结合 Dask 的 Ray actors 以利用其分析能力,都是可以应对的。
结论
本章中,你已经了解了 actor 模型的基本工作原理以及 Dask 的实现方式。你还学习了一些处理分布式系统中状态的替代方案,并学会了如何在它们之间进行选择。Dask 的 actor 是 Dask 的一个相对较新的部分,并且其容错性质与延迟函数不同。一个包含 actor 的 worker 的失败是无法恢复的。许多其他 actor 系统提供了一些从失败中恢复的能力,如果你发现自己严重依赖于 actors,你可能希望探索其他选择。
¹ 参阅 ZooKeeper 文档 了解 ZooKeeper 的分布式性能。
² Actor 模型于 1985 年被扩展用于并发计算;参见 Gul Abdulnabi Agha 的 “Actors: A Model of Concurrent Computation in Distributed Systems”。
³ 想象一下 COBOL,作者离开后文档丢失,但当你试图关闭它时,会有会计人员跑来,真的。
⁴ 粗粒度的 actor 可能包含多个状态片段;细粒度的 actor 每个状态片段都表示为一个单独的 actor。这类似于 粗粒度锁 的概念。
第八章:如何评估 Dask 的组件和库
尽管可能,但是构建由不可靠组件组成的可靠系统很难。¹ Dask 是一个主要由社区驱动的开源项目,其组件的发展速度不同。并非所有的 Dask 部分都同样成熟;即使是本书涵盖的组件也有不同的支持和发展水平。虽然 Dask 的核心部分得到了良好的维护和测试,但某些部分缺乏同等水平的维护。
尽管如此,已有数十个特定于 Dask 的流行库,开源 Dask 社区正在围绕它们不断壮大。这使我们对这些库中的许多将长期存在感到有信心。表 8-1 展示了一些基础库的非详尽列表,及其与核心 Dask 项目的关系。这旨在为用户提供路线图,并不是对个别项目的认可。尽管我们没有尝试涵盖所有显示的项目,但我们在整本书中对一些个别项目进行了评估。
表 8-1. 经常与 Dask 一起使用的库
类别 | 子类别 | 库 |
---|---|---|
Dask 项目 |
-
Dask
-
分布式
-
dask-ml
|
数据结构:扩展 Dask 内置数据结构的功能、特定科学数据处理或部署硬件选项 | 功能和便利性 |
---|
-
xarray:为 Dask 数组添加轴标签
-
sparse:稀疏数组和矩阵的高效实现,通常用于 ML 和深度学习
-
pint:科学单位转换
-
dask-geopandas:geopandas 的并行化
|
硬件 |
---|
-
RAPIDS 项目:NVIDIA 领导的项目,扩展了 CUDA 数据结构以用于 Dask
-
dask-cuda:^(a) 提供 CUDA 集群,扩展了 Dask 的集群以更好地管理支持 CUDA 的 Dask 工作节点
-
cuPY:^(a) GPU 启用的数组
-
cuDF:^(a) CUDA 数据帧作为 Dask 数据帧的分区
|
部署:扩展部署选项以与 Dask 分布式一起使用 | 容器 |
---|
-
dask-kubernetes:^(a) 在 k8s 上的 Dask
-
dask-helm:^(a) 替代 Dask 在 k8s 和 jupyterhub 在 k8s 上的使用
|
云 |
---|
-
dask-cloudprovider:商品云 API
-
dask-gateway
-
Dask-Yarn:^(a) 用于 YARN/Hadoop
|
GPU |
---|
- dask-cuda:优化 GPU 的 Dask 集群
|
HPC |
---|
-
Dask-jobqueue:^(a) 用于 PBS、Slurm、MOAB、SGE、LSF 和 HTCondor 的部署
-
dask-mpi:^(a) MPI 的部署
|
ML 和分析:通过 Dask 扩展 ML 库和计算 |
---|
-
dask-ml:^(a) 分布式实现 scikit-learn 及更多
-
xgboost:^(a) 具有原生 Dask 支持的梯度提升
-
light-gbm:^(a) 另一种基于树的学习算法,具有本地 Dask 支持
-
Dask-SQL:^(a) 基于 CPU 的 Dask SQL 引擎(ETL/计算逻辑可以在 SQL 上下文中运行;类似于 SparkSQL)
-
BlazingSQL:^(a) 基于 cuDF 和 Dask 的 SQL 查询
-
FugueSQL:^(a) 在 pandas、Dask 和 Spark 之间可移植,使用相同的 SQL 代码(缺点:需要 ANTLR,一个基于 JVM 的工具)
-
Dask-on-Ray:^(a) Dask 的分布式数据结构和任务图,在 Ray 调度器上运行
|
^(a) 本书涵盖内容。 |
---|
了解您考虑使用的组件的状态至关重要。如果您需要使用 Dask 的维护较少或开发较少的部分,防御性编程,包括彻底的代码测试,将变得更加关键。在较少建立的 Dask 生态系统部分工作,也可以是参与并贡献修复或文档的激动人心机会。
注意
这并不意味着闭源软件不会遇到相同的挑战(例如,未经测试的组件),但使用开源软件,我们可以更好地评估并做出明智的选择。
当然,并非所有项目都需要可维护性,但俗话说,“没有比临时解决方案更持久的东西。” 如果某件事确实是一次性项目,您可以跳过这里大部分的分析,试用这些库,看它们是否适合您。
Dask 正在快速发展,任何关于哪些组件是生产就绪的静态表格,在阅读时都将过时。因此,与其分享我们认为哪些 Dask 组件目前发展良好,本章旨在为您提供评估您可能考虑的库的工具。在本章中,我们将可以具体测量的指标与模糊的质量指标分开。或许反直觉地是,我们认为“模糊”的质量指标更适合评估组件和项目。
在此过程中,我们将查看一些项目及其衡量方式,但请记住,到您阅读本书时,这些具体观察结果可能已经过时,您应该使用本书提供的工具进行自己的评估。
提示
虽然我们在本章节中关注 Dask 生态系统,但您可以在软件工具选择的大多数情况下应用这些技术。
项目评估的质量考虑
我们首先关注质量工具,因为我们认为这些工具是确定特定库适合您的项目的最佳工具。
项目优先级
有些项目优先考虑基准测试或性能数字,而其他项目可能更注重正确性和清晰度,还有一些项目可能更注重完整性。项目的 README 或主页通常是项目优先考虑内容的一个良好指标。在创建早期,Apache Spark 的主页专注于性能和基准测试,而现在显示了一个更加完整的工具生态系统。Dask Kubernetes GitHub README 显示了一系列标记,指示代码的状态,除此之外几乎没有其他内容,显示出强烈的开发者关注。
尽管有许多关于是否专注于基准测试的争论,几乎永远不应牺牲正确性。² 这并不意味着库永远不会有 bug;相反,项目应认真对待正确性问题的报告,并将其视为更高优先级的问题。了解项目是否重视正确性的一个很好的方法是查看正确性问题的报告,并观察核心开发人员如何回应。
许多 Dask 生态系统项目使用 GitHub 内置的问题跟踪器,但如果您看不到任何活动,请查看 README 和开发者指南,以查看该项目是否使用不同的问题跟踪器。例如,许多 ASF 项目使用 JIRA。研究人们如何回应问题可以让您了解他们认为哪些问题重要。您不需要查看所有问题,但查看 10 个小样本通常会给您一个很好的想法(查看未解决和已关闭的问题,以及已修复和未修复的问题)。
社区
正如非官方 ASF 的一句话说的那样,“社区高于代码。”³ Apache Way 网站 将其描述为“最成功、长期存在的项目重视广泛而协作的社区,而不是代码的细节本身。” 这句话符合我们的经验,我们发现技术改进很容易从其他项目复制,但社区则难以移动。衡量社区是具有挑战性的,人们往往倾向于看开发者或用户的数量,但我们认为必须超越这一点。
找到与特定项目相关联的社区可能有些棘手。花点时间浏览问题跟踪器、源代码、论坛(如 Discourse)和邮件列表。例如,Dask 的 Discourse 组 非常活跃。有些项目使用 IRC、Slack 或 Discord,或者它们的“互动”通信方式——在我们看来,一些最好的项目会努力让这些沟通渠道的对话出现在搜索索引中。有时,社区的部分内容可能存在于外部社交媒体网站上,这对社区标准提出了独特的挑战。
开源软件项目有多种类型的社区。用户社区是那些使用软件构建事物的人。开发者社区是致力于改进库的群体。一些项目的这两个社区之间有很大的交集,但通常用户社区远远大于开发者社区。我们倾向于评估开发者社区,但确保两者都健康也很重要。开发者不足的软件项目会进展缓慢,没有用户的项目通常对除了开发者以外的任何人都很难使用。
在许多情况下,一个拥有足够多混混(或者一个主要的混混)的大社区可能比一个由友善的人组成的小社区环境不那么令人愉快。如果你不喜欢你的工作,你就不太可能是高效的。可悲的是,判断某人是否是混混,或者某个社区是否存在混混,是一个复杂的问题。如果人们在邮件列表或问题跟踪器上通常表现粗鲁,这可能是社区对新成员不那么友好的迹象。(参见 4)
注意
一些项目,包括 Holden 的一个项目,已经尝试使用情感分析结合随机抽样来量化这些指标,但这是一个耗时的过程,在大多数情况下你可能可以跳过。(参见 sentiment analysis combined with random sampling)
即使是最友善的人,贡献者所属的机构也可能很重要。例如,如果顶级贡献者都是同一个研究实验室的研究生或在同一家公司工作,软件被遗弃的风险会增加。这并不是说单一公司或甚至单个人的开源项目就是坏事,(参见 5)但你应该调整你的期望来匹配这一点。
注意
如果你担心某个项目不符合你当前的成熟度水平,并且你有预算,这可能是支持关键开源项目的绝佳机会。与维护者联系,看看他们需要什么;有时,简单地给他们写一张新硬件的支票或者雇佣他们为你的公司提供培训就可以了。
除了一个社区中的人是否友好外,如果人们使用项目的方式与你考虑使用的方式类似,这也可能是一个积极的信号。例如,如果你是第一个将 Dask DataFrames 应用到一个新领域的人,尽管 Dask DataFrames 本身非常成熟,你更有可能发现缺失的组件,而不是如果同一领域的其他人已经在使用 Dask。
Dask 特定的最佳实践
当涉及到 Dask 库时,有一些特定于 Dask 的最佳实践需要注意。总体来说,库不应在客户端节点上做太多的工作,尽可能多的工作应委托给工作节点。有时文档会掩盖哪些部分发生在哪里,而我们的经验中最快的方法是简单地运行示例代码,并查看哪些任务被安排在工作节点上。相关地,库在可能时应尽可能只返回最小的数据块。这些最佳实践与编写自己的 Dask 代码时略有不同,因为你可以预先知道你的数据大小,并确定何时本地计算是最佳路径。
最新的依赖项
如果一个项目固定了依赖项的特定版本,重要的是固定的版本不会与你想使用的其他包发生冲突,更重要的是,不会固定不安全的依赖项。什么算是“最新”的是一个主观问题。如果你是喜欢使用一切最新版本的开发者,你可能会最喜欢(大部分)提供最低但不是最高版本的库。然而,这可能会误导,特别是在 Python 生态系统中,许多库并不使用语义版本控制—包括 Dask,它使用CalVer—而且仅仅因为一个项目不排除新版本并不意味着它实际上可以与之一起工作。
注意
有些人可能会称之为定量的,但在一个以 CalVer 为中心的生态系统中,我们认为这更多是定性的。
在考虑将新库添加到现有环境中时,一个好的检查是尝试在你计划使用它的虚拟环境中(或等效配置的环境中)运行新库的测试套件。
文档
虽然不是每个工具都需要一本书(尽管我们希望你会觉得书籍有用),但真正不需要解释的库却是非常少的。在低端,对于简单的库,一些示例或者写得很好的测试可以代替适当的文档。完整的文档是项目整体成熟的一个良好标志。并非所有的文档都是平等的,正如谚语所说,文档完成时通常已经过时了(如果不是之前)。在你完全深入了解一个新库之前,打开文档并尝试运行示例是一个很好的练习。如果入门示例无法运行(而且你无法弄清楚如何修复它们),你可能会遇到一些麻烦。
有时存在很好的文档,但与项目分离(例如在书籍中),可能需要进行一些研究。如果发现一个项目有良好但不明显的文档,考虑尝试提高文档的可见性。
对贡献的开放态度
如果你发现某个库很有前途但还不够完善,能够贡献你的改进至关重要。这对社区是有益的,此外,如果不能将改进内容贡献给库,将来升级到新版本将更具挑战性。⁶ 当今许多项目都有贡献指南,可以让你了解他们的工作方式,但是没有什么比真正的测试贡献更好。开始一个项目的好方法是以新手的视角修复其文档,特别是从前一节获取开始的示例。文档在快速发展的项目中往往会变得过时,如果你发现难以让你的文档变更被接受,这表明贡献更复杂的改进将会多么具有挑战性。
需要注意的是问题报告体验。由于几乎没有软件是完全没有缺陷的,你可能会遇到问题。无论你是否有精力或技能修复这个错误,分享你的经验至关重要,以便修复它。分享这个问题可以帮助下一个遇到相同挑战的人感到不孤单,即使问题没有解决。
注意
在尝试报告问题时,要注意你的体验。大多数有活跃社区的大型项目都会有一些指导,帮助你提交问题并确保不重复之前的问题。如果缺乏这些指导(或者项目的社区规模较小),报告问题可能会更具挑战性。
如果你没有时间进行自己的测试贡献,你总可以查看项目的拉取请求(或等效物),看看回应是否积极或对抗性。
可扩展性
并非所有对库的更改都必须能够上游。如果一个库结构合理,你可以在不更改基础代码的情况下添加额外的功能。Dask 之所以强大的一部分原因就是它的可扩展性。例如,添加用户定义的函数和聚合允许 Dask 被许多人使用。
用于开源项目评估的定量指标
作为软件开发人员和数据科学家,我们经常试图使用定量指标来做出决策。软件的定量指标,无论是开源还是闭源,都是一个活跃研究领域,因此我们无法覆盖所有的定量指标。所有开源项目的一个重大挑战是,特别是一旦涉及到资金,这些指标可能会受到影响。我们建议集中精力关注定性因素,虽然这些因素更难以衡量,但也更难以被操纵。
这里我们涵盖了一些人们常常试图使用的常见指标,还有许多其他评估开源项目可用性的框架,包括OSSM、OpenSSF 安全度量,和更多。其中一些框架表面上产生了自动化评分(如 OpenSSF),但根据我们的经验,这些度量不仅可被操纵,而且通常被错误地收集。⁷
发布历史
频繁的发布可能是一个健康库的标志。如果一个项目很长时间没有发布过,你更有可能与其他库发生冲突。对于建立在诸如 Dask 之类的工具之上的库,要检查的一个细节是新版本库在最新版本 Dask 之上发布需要多长时间(或者是多少天)。有些库不会进行传统的发布,而是建议直接从源代码仓库安装。这通常是一个项目处于开发早期阶段的迹象,这样的项目作为一个依赖项更具挑战性。⁸
发布历史是最容易被操纵的指标之一,因为它只需要开发人员发布一个版本。某些开发风格会在每次成功提交后自动创建发布版本,而在我们看来,这通常是一种反模式,⁹ 因为你通常希望在全面发布之前进行一些额外的人工测试或检查。
提交频率(和数量)
人们考虑的另一个流行指标是提交频率或数量。这个指标远非完美,因为频率和数量可能会因编码风格而异,而编码风格与软件质量没有相关性。例如,倾向于压缩提交的开发人员可能会具有较低的提交数量,而主要使用 rebase 的开发人员则会有更高的提交数量。
另一方面,最近提交完全缺乏可能是项目已经被抛弃的迹象,如果你决定使用它,最终会不得不维护一个分支。
库的使用情况
最简单的指标之一是人们是否在使用某个包,你可以通过查看安装情况来判断。你可以在PyPI 统计网站(见图 8-1)或Google 的 BigQuery上检查 PyPI 包的安装统计数据,以及使用condastats 库检查 conda 安装情况。
不幸的是,安装计数是一个嘈杂的指标,因为 PyPI 下载可能来自于任何地方,从 CI 管道到甚至有人启动了一个安装了库但从未使用过的新集群。这个指标不仅是无意的嘈杂,而且相同的技术也可以被用来人为地增加数字。
我们不希望过于依赖包安装数量,而是希望能找到人们使用库的实际例子,比如在 GitHub 或 Sourcegraph 上搜索导入情况。例如,我们可以尝试通过在 Sourcegraph 上搜索 (file:requirements.txt OR file:setup.py) cudf AND dask
和 (file:requirements.txt OR file:setup.py) streamz AND dask
来获取使用 Streamz 或 cuDF 与 Dask 的人数近似值,分别为 72 和 33。这只能捕捉到一部分情况,但当我们将其与 Dask 的相同查询比较时(得到 500+),这表明在 Dask 生态系统中,Streamz 的使用率低于 cuDF。
图 8-1. Dask Kubernetes 安装统计数据来自 PyPI 统计
寻找人们使用某个库的例子有其局限性,特别是在数据处理方面。由于数据和机器学习管道并不经常开源,因此对于用于这些目的的库,找到例子可能更加困难。
另一个可以参考使用情况的指标是问题或邮件列表帖子的频率。如果项目托管在类似 GitHub 的平台上,星星数量也可以作为衡量使用情况的一种有趣方式——但由于人们现在可以像购买 Instagram 点赞一样购买 GitHub 星星(如图 8-2 所示),因此不应过分依赖此指标。¹⁰
即使不考虑购买星星的情况,什么样的项目值得加星也因人而异。一些项目会请求许多人加星,虽然没有购买星星,但这可能会快速增加此指标。¹¹
图 8-2. 有人在出售 GitHub 星星
代码和最佳实践
软件测试对许多软件工程师来说是本能反应,但有时项目是匆忙创建的,没有测试。如果一个项目没有测试,或者测试大部分不通过,那么很难对项目的行为有信心。即使是最专业的项目,有时在测试方面也会偷懒,增加更多的测试是确保项目继续按您需要的方式运行的好方法。一个好问题是测试是否覆盖了对您重要的部分。如果一个项目确实有相关的测试,下一个自然的问题是它们是否被使用。如果运行测试太困难,人性往往会占上风,测试可能就不会被运行。因此,一个好的步骤是尝试运行项目中的测试。
注意
测试覆盖率数字可以是特别有信息量的,但不幸的是,对于建立在像 Dask 这样的系统之上的项目,¹² 获得准确的测试覆盖信息是一个挑战。在单机系统中,测试覆盖率可以是一个很好的自动计算的定量指标。
我们认为大多数优秀的库都会有某种形式的持续集成(CI)或自动化测试,包括对提议更改的检查(或创建拉取请求时)。您可以通过查看拉取请求标签来检查 GitHub 项目是否有持续集成。CI 对于总体上减少错误尤其是回归错误非常有帮助。¹³ 历史上,使用 CI 在某种程度上取决于项目的偏好,但随着包括 GitHub actions 在内的免费工具的创建,许多多人软件项目现在都有某种形式的 CI。这是一种常见的软件工程实践,我们认为对于我们依赖的库来说是必不可少的。
静态类型经常被认为是编程的最佳实践,尽管也有一些反对者。虽然关于数据流水线内部的静态类型的争论是复杂的,但我们认为在库级别至少应该期待一些类型。
结论
在构建基于 Dask 的数据(或其他)应用程序时,您可能需要来自生态系统的许多不同工具。生态系统以不同的速度发展,其中一些部分需要更多的投资,才能有效地使用它们。选择正确的工具,以及因果关系正确的人员,是决定您的项目是否成功以及在我们的经验中,您的工作愉快程度的关键因素。重要的是要记住,这些决定并不是一成不变的,但随着在项目中使用库的时间越长,更改库变得更加困难。在本章中,您已经学会了如何评估生态系统不同组件的项目成熟度。您可以利用这些知识来决定何时使用库而不是自己编写所需的功能。
¹ 尽管在许多方面,分布式系统已经发展到可以克服其不可靠的组件。例如,容错性是单台机器无法实现的,但分布式系统可以通过复制来实现。
² 牺牲正确性意味着产生不正确的结果。一个正确性问题的例子是 Dask-on-Ray 中的 set_index
导致行消失;这个问题花了大约一个月的时间来修复,在我们看来是相当合理的,考虑到复现这个问题的挑战。有时,像安全修复一样,正确性修复可能导致处理速度变慢;例如,MongoDB 的默认设置非常快,但可能会丢失数据。
³ 我们不确定这句引文确切的来源和出处;它出现在 ASF 董事的立场声明中,也出现在 Apache Way 文档中。
⁴ Linux 内核是一个典型的稍微更具挑战性的社区的例子。
⁵ 一个小社区开发了一个非常受欢迎和成功的项目的例子是 homebrew。
⁶ 从上游开源项目中无法贡献回来的更改意味着每次升级都需要重新应用这些更改。虽然现代工具如 Git 简化了这一过程的机制,但这可能是一个耗时的过程。
⁷ 例如,OpenSSF 报告称 Apache Spark 有未签名的发布版本,但所有发布版本均已签名。像 log4j 这样的关键项目错误地被低估了关键性评分,说明这些指标的局限性。
⁸ 在这些情况下,最好选择一个标签或提交来安装,以免出现版本不匹配的情况。
⁹ 快照工件是可以接受的。
¹⁰ 有一些工具可以帮助你更深入地挖掘星级数据,包括ghrr,但我们仍认为不要花太多时间或者给予星级太多的权重。
¹¹ 例如,我们可能要求你为我们的示例仓库点赞,通过这样做,我们(希望)能增加星级数量,而无需实际上提高我们的质量。
¹² 这是因为大多数检查代码覆盖率的 Python 工具假定只有一个 Python 虚拟机需要附加并查看执行的代码部分。然而,在分布式系统中,情况不再如此,许多这些自动化工具无法正常工作。
¹³ 当某些在较新版本中工作正常的东西停止工作时。
第九章:迁移现有分析工程
许多用户已经部署了当前正在使用的分析工作,他们希望将其迁移到 Dask。本章将讨论用户进行切换时的考虑、挑战和经验。本章主要探讨将现有大数据工程作业从其他分布式框架(如 Spark)迁移到 Dask 的主要迁移路径。
为什么选择 Dask?
以下是考虑从现有在 pandas 中实现的作业或 PySpark 等分布式库迁移到 Dask 的一些理由:
Python 和 PyData 堆栈
许多数据科学家和开发人员更喜欢使用 Python 本地堆栈,他们不需要在不同语言或风格之间切换。
与 Dask API 更丰富的 ML 集成
Futures、delayed 和 ML 集成要求开发人员减少粘合代码的编写,由于 Dask 提供更灵活的任务图管理,性能有所提升。
精细化任务管理
Dask 的任务图在运行时实时生成和维护,并且用户可以同步访问任务字典。
调试开销
一些开发团队更喜欢 Python 中的调试体验,而不是混合 Python 和 Java/Scala 堆栈跟踪。
开发开销
在 Dask 中进行开发步骤可以轻松在开发者的笔记本电脑上完成,而不需要连接到强大的云机器以进行实验。
管理用户体验
Dask 的可视化工具往往更具视觉吸引力和直观性,具有用于任务图的本地 graphviz 渲染。
这些并非所有的优势,但如果其中任何一个对你有说服力,考虑将工作负载转移到 Dask 可能是值得投资时间考虑的。总是会有权衡,因此接下来的部分将讨论一些限制,并提供一个路线图,以便让你了解迁移到 Dask 所涉及的工作规模。
Dask 的限制
Dask 是比较新的技术,使用 Python 数据堆栈执行大规模抽取、转换和加载操作也是相对较新的。Dask 存在一些限制,主要是因为 PyData 堆栈传统上并不用于执行大规模数据工作负载。在撰写本文时,系统存在一些限制。然而,开发人员正在解决这些问题,许多这些不足将会被弥补。你应该考虑一些精细化的注意事项,如下所述:
Parquet 的规模限制
如果 Parquet 数据超过 10 TB,fastparquet 和 PyArrow 层面会出现问题,这会拖慢 Dask 的速度,并且元数据管理的开销可能会很大。
在 Parquet 文件达到 10 TB 以上的 ETL 工作负载中,包括追加和更新等变异,会遇到一致性问题。
弱数据湖集成
PyData 堆栈在传统上并没有在大数据领域大量使用,并且在数据湖管理方面的集成,如 Apache Iceberg,尚未完善。
高级查询优化
Spark 的用户可能熟悉 Catalyst 优化器,该优化器推动优化执行器上的物理工作。目前 Dask 还缺少这种优化层。Spark 在早期也没有写 Catalyst 引擎,目前正在进行相关工作,以为 Dask 构建此功能。
像 Dask 这样快速发展的项目的任何限制列表,在你阅读时可能已经过时,因此如果这些限制是您迁移的阻碍因素,请确保检查 Dask 的状态跟踪器。
迁移路线图
虽然没有工程工作是线性进行的,但随时掌握路线图始终是个好主意。我们已经列出了迁移步骤的示例,作为团队在计划迁移时可能需要考虑的非穷尽列表项:
-
我们将希望在什么类型的机器和容器化框架上部署 Dask,它们各自的优缺点是什么?
-
我们是否有测试来确保我们的迁移正确性和我们期望的目标?
-
Dask 能够摄取什么类型的数据,在什么规模下,以及这与其他平台有何不同?
-
Dask 的计算框架是什么,以及我们如何以 Dask 和 Pythonic 的方式思考来完成任务?
-
我们将如何在运行时监控和排除代码问题?
我们将从查看集群类型开始,这与部署框架相关,因为这通常是需要与其他团队或组织合作的问题之一。
集群类型
如果您考虑迁移您的分析工程工作,您可能拥有一个由您的组织提供的系统。Dask 在许多常用的部署和开发环境中受到支持,其中一些允许更灵活的扩展、依赖管理和支持异构工作类型。我们在学术环境、通用云和直接在虚拟机/容器上使用了 Dask;我们详细说明了各自的优缺点以及一些广泛使用和支持的环境,详见 附录 A。
示例 9-1 展示了 YARN 部署的示例。更多示例和深入讨论可见于 第十二章。
示例 9-1. 使用 Dask-Yarn 和 skein 在 YARN 上部署 Dask
from dask_yarn import YarnCluster
from dask.distributed import Client
# Create a cluster where each worker has two cores and 8 GiB of memory
cluster = YarnCluster(
environment='your_environment.tar.gz',
worker_vcores=2,
worker_memory="4GiB")
# Scale out to num_workers such workers
cluster.scale(num_workers)
# Connect to the cluster
client = Client(cluster)
如果您的组织有多个受支持的集群,选择一个可以自助依赖管理的集群,如 Kubernetes,将是有益的。
对于使用 PBS、Slurm、MOAB、SGE、LSF 和 HTCondor 等作业队列系统进行高性能计算部署,应使用 Dask-jobqueue,如 示例 9-2 所示。
示例 9-2. 使用 jobqueue 在 Slurm 上部署 Dask
from dask_jobqueue import SLURMCluster
from dask.distributed import Client
cluster = SLURMCluster(
queue='regular',
account="slurm_caccount",
cores=24,
memory="500 GB"
)
cluster.scale(jobs=SLURM_JOB_COUNT) # Ask for N jobs from Slurm
client = Client(cluster)
# Auto-scale between 10 and 100 jobs
cluster.adapt(minimum_jobs=10, maximum_jobs=100)
cluster.adapt(maximum_memory="10 TB") # Or use core/memory limits
你可能已经由你的组织管理员设置了共享文件系统。企业用户可能已经习惯了在 HDFS 或像 S3 这样的 Blob 存储上运行的健全配置的分布式数据源,而 Dask 能够无缝地与之配合(参见示例 9-3)。Dask 也与网络文件系统良好集成。
示例 9-3. 使用 MinIO 读取和写入 Blob 存储
import s3fs
import pyarrow as pa
import pyarrow.parquet as pq
minio_storage_options = {
"key": MINIO_KEY,
"secret": MINIO_SECRET,
"client_kwargs": {
"endpoint_url": "http://ENDPOINT_URL",
"region_name": 'us-east-1'
},
"config_kwargs": {"s3": {"signature_version": 's3v4'}},
}
df.to_parquet(f's3://s3_destination/{filename}',
compression="gzip",
storage_options=minio_storage_options,
engine="fastparquet")
df = dd.read_parquet(
f's3://s3_source/',
storage_options=minio_storage_options,
engine="pyarrow"
)
我们发现一个令人惊讶地有用的用例是直接连接到网络存储,如 NFS 或 FTP。在处理大型且难以处理的学术数据集时(例如直接由另一个组织托管的神经影像数据集),我们可以直接连接到源文件系统。使用 Dask 这种方式时,你应该测试并考虑网络超时的允许。此外,请注意,截至本文撰写时,Dask 尚未具备与 Iceberg 等数据湖的连接器。
开发:考虑因素
将现有逻辑转换为 Dask 是一个相当直观的过程。以下部分介绍了如果你来自 R、pandas 和 Spark 等库,并且 Dask 可能与它们有何不同的一些考虑因素。其中一些差异来自于从不同的低级实现(如 Java)移动,其他差异来自于从单机代码移动到扩展实现,例如从 pandas 移动而来。
DataFrame 性能
如果你已经在不同平台上运行作业,很可能已经在运行时使用列存储格式,例如 Parquet。从 Parquet 到 Python 的数据类型映射固有地不精确。建议在运行时读取任何数据时检查数据类型,DataFrame 亦如此。如果类型推断失败,列会默认为对象。一旦检查并确定类型推断不精确,指定数据类型可以显著加快作业速度。此外,检查字符串、浮点数、日期时间和数组总是个好主意。如果出现类型错误,牢记上游数据源及其数据类型是一个好的开始。例如,如果 Parquet 是从协议缓冲生成的,根据使用的编码和解码引擎,该堆栈中引入了空检查、浮点数、双精度和混合精度类型的差异。
当从云存储读取大文件到 DataFrame 时,在 DataFrame 读取阶段预先选择列可能非常有用。来自其他平台(如 Spark)的用户可能熟悉谓词下推,即使你没有完全指定所需的列,平台也会优化并仅读取计算所需的列。Dask 目前尚未提供这种优化。
在 DataFrame 转换早期设置智能索引,在复杂查询之前,可以加快速度。请注意,Dask 尚不支持多索引。对于来自其他平台的多索引 DataFrame 的常见解决方法是映射为单一连接列。例如,从非 Dask 列数据集(如 pandas 的 pd.MultiIndex
,其索引有两列 col1
和 col2
)来时的一个简单解决方法是在 Dask DataFrame 中引入一个新列 col1_col2
。
在转换阶段,调用 .compute()
方法将大型分布式 Dask DataFrame 合并为一个单一分区,应该可以放入 RAM 中。如果不行,可能会遇到问题。另一方面,如果您已经将大小为 100 GB 的输入数据过滤到了 10 GB(假设您的 RAM 是 15 GB),那么在过滤操作后减少并行性可能是个好主意,方法是调用 .compute()
。您可以通过调用 df.memory_usage(deep=True).sum()
来检查 DataFrame 的内存使用情况,以确定是否需要进行此操作。如果在过滤操作后有复杂且昂贵的洗牌操作,比如与新的更大数据集的 .join()
操作,这样做尤其有用。
提示
与 pandas DataFrame 用户熟悉的内存中值可变不同,Dask DataFrame 不支持这种方式的值可变。由于无法在内存中修改特定值,唯一的改变值的方式将是对整个 DataFrame 列进行映射操作。如果经常需要进行内存中值的更改,最好使用外部数据库。
将 SQL 迁移到 Dask
Dask 并不原生支持 SQL 引擎,尽管它原生支持从 SQL 数据库读取数据的选项。有许多不同的库可以用来与现有的 SQL 数据库交互,并且将 Dask DataFrame 视为 SQL 表格并直接运行 SQL 查询(参见 示例 9-4)。一些库甚至允许您直接构建和提供 ML 模型,使用类似于 Google BigQuery ML 的 SQL ML 语法。在示例 11-14 和 11-15 中,我们将展示使用 Dask 的原生 read_sql()
函数以及使用 Dask-SQL 运行 SQL ML 的用法。
示例 9-4. 从 Postgres 数据库读取
df = dd.read_sql_table('accounts', 'sqlite:///path/to/your.db',
npartitions=10, index_col='id')
FugueSQL 为 PyData 栈(包括 Dask)提供了 SQL 兼容性。该项目处于起步阶段,但似乎很有前途。FugueSQL 的主要优势在于代码可以在 pandas、Dask 和 Spark 之间进行移植,提供了更多的互操作性。FugueSQL 可以使用 DaskExecutionEngine
运行其 SQL 查询,或者在已经使用的 Dask DataFrame 上运行 FugueSQL 查询。或者,你也可以在笔记本上快速在 Dask DataFrame 上运行 SQL 查询。示例 9-5 展示了在笔记本中使用 FugueSQL 的示例。FugueSQL 的缺点是需要 ANTLR 库,而 ANTLR 又依赖于 Java 运行时。
示例 9-5. 使用 FugueSQL 在 Dask DataFrame 上运行 SQL
from fugue_notebook import setup
setup (is_lab=True)
ur = ('https://d37ci6vzurychx.cloudfront.net/trip-data/'
'yellow_tripdata_2018-01.parquet')
df = dd.read_parquet(url)
%%fsql dask
tempdf = SELECT VendorID, AVG (total_amount) AS average_fare FROM df
GROUP BY VendorID
SELECT *
FROM tempdf
ORDER BY average fare DESC
LIMIT 5
PRINT
VendorID | average_fare | |
---|---|---|
0 | 1 | 15.127384 |
1 | 2 | 15.775723 |
schema: VendorID:long, average_fare:double
另一种方法是使用 Dask-SQL 库。该软件包使用 Apache Calcite 提供 SQL 解析前端,并用于查询 Dask 数据帧。使用该库,你可以将大多数基于 SQL 的操作传递给 Dask-SQL 上下文,并进行处理。引擎处理标准 SQL 输入,如 SELECT
、CREATE TABLE
,同时还支持使用 CREATE MODEL
语法进行 ML 模型创建。
部署监控
像许多其他分布式库一样,Dask 提供日志记录功能,你可以配置 Dask 日志将其发送到存储系统。部署环境会影响方法的选择,以及是否涉及 Jupyter。
Dask 客户端暴露了 get_worker_logs()
和 get_scheduler_logs()
方法,如果需要可以在运行时访问。此外,类似于其他分布式系统的日志记录,你可以按主题记录事件,使其易于按事件类型访问。
示例 9-6 是在客户端添加自定义日志事件的玩具示例。
示例 9-6. 按主题进行基本日志记录
from dask.distributed import Client
client = Client()
client.log_event(topic="custom_events", msg="hello world")
client.get_events("custom_events")
示例 9-7 在前一个示例的基础上构建,但是将执行上下文切换到分布式集群设置中,以处理可能更复杂的自定义结构化事件。Dask 客户端监听并累积这些事件,我们可以进行检查。我们首先从一个 Dask 数据帧开始,然后执行一些计算密集型任务。本示例使用 softmax
函数,这是许多 ML 应用中常见的计算。常见的 ML 困境是是否使用更复杂的激活或损失函数来提高准确性,牺牲性能(从而运行更少的训练周期,但获得更稳定的梯度),反之亦然。为了弄清楚这一点,我们插入一个代码来记录定制的结构化事件,以计算特定函数的计算开销。
示例 9-7. 工作节点上的结构化日志
from dask.distributed import Client, LocalCluster
client = Client(cluster) # Connect to distributed cluster and override default
d = {'x': [3.0, 1.0, 0.2], 'y': [2.0, 0.5, 0.1], 'z': [1.0, 0.2, 0.4]}
scores_df = dd.from_pandas(pd.DataFrame(data=d), npartitions=1)
def compute_softmax(partition, axis=0):
""" computes the softmax of the logits
:param logits: the vector to compute the softmax over
:param axis: the axis we are summing over
:return: the softmax of the vector
"""
if partition.empty:
return
import timeit
x = partition[['x', 'y', 'z']].values.tolist()
start = timeit.default_timer()
axis = 0
e = np.exp(x - np.max(x))
ret = e / np.sum(e, axis=axis)
stop = timeit.default_timer()
partition.log_event("softmax", {"start": start, "x": x, "stop": stop})
dask.distributed.get_worker().log_event(
"softmax", {"start": start, "input": x, "stop": stop})
return ret
scores_df.apply(compute_softmax, axis=1, meta=object).compute()
client.get_events("softmax")
结论
在本章中,您已经审查了迁移现有分析工程工作的重要问题和考虑因素。您还了解了 Dask 与 Spark、R 和 pandas 之间的一些特征差异。一些特性尚未由 Dask 实现,一些特性则由 Dask 更为稳健地实现,还有一些是在将计算从单机迁移到分布式集群时固有的翻译差异。由于大规模数据工程倾向于在许多库中使用类似的术语和名称,往往容易忽视导致更大性能或正确性问题的细微差异。记住它们将有助于您在 Dask 中迈出第一步的旅程。
第十章:使用 GPU 和其他特殊资源的 Dask
有时解决我们的扩展问题的答案并不是增加更多计算机,而是投入不同类型的资源。一个例子是一万只猴子试图复制莎士比亚的作品,与一个莎士比亚¹。尽管性能有所不同,但一些基准测试显示,使用 GPU 而不是 CPU 在模型训练时间上的提升可以高达 85%。继续其模块化传统,Dask 的 GPU 逻辑存在于围绕其构建的库和生态系统中。这些库可以在一组 GPU 工作节点上运行,也可以在一个主机上的不同 GPU 上并行工作。
我们在计算机上进行的大部分工作都是由 CPU 完成的。GPU 最初是用于显示视频,但涉及大量矢量化浮点(例如非整数)运算。通过矢量化运算,相同的操作并行应用于大量数据集,类似于map
操作。张量处理单元(TPU)类似于 GPU,但不用于图形显示。
对于我们在 Dask 中的目的,我们可以将 GPU 和 TPU 视为专门用于卸载大规模矢量化计算的设备,但还有许多其他类型的加速器。虽然本章的大部分内容集中在 GPU 上,但相同的一般技术通常也适用于其他加速器,只是使用了不同的库。其他类型的特殊资源包括 NVMe 驱动器、更快(或更大)的 RAM、TCP/IP 卸载、Just-a-Bunch-of-Disks 扩展端口以及英特尔的 OPTAIN 内存。特殊资源/加速器可以改善从网络延迟到大文件写入到磁盘的各个方面。所有这些资源的共同点在于,Dask 并没有内置对这些资源的理解,您需要为 Dask 调度器提供这些信息,并利用这些资源。
本章将探讨 Python 中加速分析的当前状态以及如何与 Dask 结合使用这些工具。您将了解到哪些问题适合使用 GPU 加速,以及其他类型加速器的相关信息,以及如何将这些知识应用到您的问题中。
警告
由于挖掘加密货币的相对简易性,云账户和拥有 GPU 访问权限的机器特别受到互联网上不良分子的关注。如果您习惯于仅使用公共数据和宽松的安全控制,这是一个审视您的安全流程并限制运行时访问仅限于需要的人的机会。否则可能会面临巨额云服务账单。
透明与非透明加速器
加速器主要分为两类:透明(无需代码或更改)和非透明优化器。加速器是否透明在很大程度上取决于我们在堆栈下面的某人是否使其对我们透明化。
在用户空间级别,TCP/IP 卸载通常是透明的,这意味着操作系统为我们处理它。NVMe 驱动器通常也是透明的,通常看起来与传统磁盘相同,但速度更快。仍然很重要的是让 Dask 意识到透明优化器;例如,应将磁盘密集型工作负载安排在具有更快磁盘的机器上。
非透明加速器包括 GPUs、Optane、QAT 等。使用它们需要修改我们的代码以充分利用它们。有时这可能只需切换到不同的库,但并非总是如此。许多非透明加速器要求要么复制我们的数据,要么进行特殊格式化才能运行。这意味着如果一个操作相对较快,转移到优化器可能会使其变慢。
理解 GPU 或 TPU 是否有助于解决问题
并非每个问题都适合 GPU 加速。 GPUs 特别擅长在同一时间对大量数据点执行相同的计算。如果一个问题非常适合矢量化计算,那么 GPU 可能非常适合。
一些通常受益于 GPU 加速的常见问题包括:
-
机器学习
-
线性代数
-
物理模拟
-
图形(毫不意外)
GPUs 不太适合分支繁多且非矢量化的工作流程,或者数据复制成本与计算成本相似或更高的工作流程。
使 Dask 资源感知
如果您已经确定您的问题非常适合专用资源,下一步是让调度器意识到哪些机器和进程拥有该资源。您可以通过添加环境变量或命令行标志到工作进程启动(例如 --resources "GPU=2"
或 DASK_DISTRIBUTED_WORKER_RESOURCES_GPU=2
)来实现这一点。
对于 NVIDIA 用户来说,dask-cuda
软件包可以每个 GPU 启动一个工作进程,将 GPU 和线程捆绑在一起以提升性能。例如,在我们的带有 GPU 资源的 Kubernetes 集群上,我们配置工作进程使用 dask-cuda-worker
启动器,如 示例 10-1 所示。
示例 10-1. 在 Dask Kubernetes 模板中使用 dask-cuda-worker
软件包
worker_template = make_pod_spec(image='holdenk/dask:latest',
memory_limit='8G', memory_request='8G',
cpu_limit=1, cpu_request=1)
worker_template.spec.containers[0].resources.limits["gpu"] = 1
worker_template.spec.containers[0].resources.requests["gpu"] = 1
worker_template.spec.containers[0].args[0] = "dask-cuda-worker --resources 'GPU=1'"
worker_template.spec.containers[0].env.append("NVIDIA_VISIBLE_DEVICES=ALL")
# Or append --resources "GPU=2"
在这里,我们仍然添加 --resources
标志,以便在混合环境中仅选择 GPU 工作进程。
如果您使用 Dask 在单台计算机上安排多个 GPU 的工作(例如使用带有 CUDA 的 Dask 本地模式),同样的 dask-cuda
软件包提供了 LocalCUDACluster
。与 dask-cuda-worker
类似,您仍然需要手动添加资源标记,如 示例 10-2 所示,但 LocalCUDACluster
可以启动正确的工作进程并将它们固定在线程上。
示例 10-2. 带有资源标记的 LocalCUDACluster
from dask_cuda import LocalCUDACluster
from dask.distributed import Client
#NOTE: The resources= flag is important; by default the
# LocalCUDACluster *does not* label any resources, which can make
# porting your code to a cluster where some workers have GPUs and
# some don't painful.
cluster = LocalCUDACluster(resources={"GPU": 1})
client = Client(cluster)
注
对于均质集群来说,可能会有诱惑避免标记这些资源,但除非您始终将工作进程/线程与加速器进行 1:1 映射(或加速器可以同时被所有工作进程使用),否则标记这些资源仍然是有益的。这对于像 GPU/TPU 这样的非共享(或难以共享)资源尤为重要,因为 Dask 可能会安排两个试图访问 GPU 的任务。但对于像 NVMe 驱动器或 TCP/IP 卸载这样的共享资源,如果它在集群中的每个节点上都存在且始终存在,则可能可以跳过标记。
需要注意的是,Dask 不管理自定义资源(包括 GPU)。如果另一个进程在没有经过 Dask 请求的情况下使用了所有 GPU 核心,这是无法保护的。在某些方面,这类似于早期的计算方式,即我们使用“协作式”多任务处理;我们依赖于邻居的良好行为。
警告
Dask 依赖于行为良好的 Python 代码,即不会使用未请求的资源,并在完成时释放资源。这通常发生在内存泄漏(包括加速和非加速)时,尤其是在像 CUDA 这样的专门库中分配 Python 之外的内存。这些库通常在完成任务后需要调用特定步骤,以便让资源可供其他人使用。
安装库
现在 Dask 已经意识到集群上的特殊资源,是时候确保您的代码能够利用这些资源了。通常情况下,这些加速器会要求安装某种特殊库,可能需要较长的编译时间。在可能的情况下,从 conda 安装加速库,并在工作节点(容器或主机上)预先安装,可以帮助减少这种开销。
对于 Kubernetes(或其他 Docker 容器用户),您可以通过创建一个预先安装了加速器库的自定义容器来实现这一点,如示例 10-3 所示。
示例 10-3. 预安装 cuDF
# Use the Dask base image; for arm64, though, we have to use custom built
# FROM ghcr.io/dask/dask
FROM holdenk/dask:latest
# arm64 channel
RUN conda config --add channels rpi
# Numba and conda-forge channels
RUN conda config --add channels numba
RUN conda config --add channels conda-forge
# Some CUDA-specific stuff
RUN conda config --add channels rapidsai
# Accelerator libraries often involve a lot of native code, so it's
# faster to install with conda
RUN conda install numba -y
# GPU support (NV)
RUN conda install cudatoolkit -y
# GPU support (AMD)
RUN conda install roctools -y || echo "No roc tools on $(uname -a)"
# A lot of GPU acceleration libraries are in the rapidsai channel
# These are not installable with pip
RUN conda install cudf -y
然后,为了构建这个,我们运行示例 10-4 中显示的脚本。
示例 10-4. 构建自定义 Dask Docker 容器
#/bin/bash
set -ex
docker buildx build -t holdenk/dask-extended --platform \
linux/arm64,linux/amd64 --push . -f Dockerfile
docker buildx build -t holdenk/dask-extended-notebook --platform \
linux/arm64,linux/amd64 --push . -f NotebookDockerfile
在您的 Dask 任务中使用自定义资源
您必须确保需要加速器的任务在具有加速器的工作进程上运行。您可以在使用 Dask 调度任务时通过 client.submit
显式地请求特殊资源,如示例 10-5 所示,或通过向现有代码添加注释,如示例 10-6 所示。
示例 10-5. 提交一个请求使用 GPU 的任务
future = client.submit(how_many_gpus, 1, resources={'GPU': 1})
示例 10-6. 注释需要 GPU 的一组操作
with dask.annotate(resources={'GPU': 1}):
future = client.submit(how_many_gpus, 1)
如果从具有 GPU 资源的集群迁移到没有 GPU 资源的集群,则此代码将无限期挂起。后面将介绍的 CPU 回退设计模式可以缓解这个问题。
装饰器(包括 Numba)
Numba 是一个流行的高性能 JIT 编译库,也支持各种加速器。 大多数 JIT 代码以及许多装饰器函数通常不会直接序列化,因此尝试直接使用dask.submit
Numba(如示例 10-7 所示)不起作用。 相反,正确的方法是像示例 10-8 中所示那样包装函数。
示例 10-7. 装饰器难度
# Works in local mode, but not distributed
@dask.delayed
@guvectorize(['void(float64[:], intp[:], float64[:])'],
'(n),()->(n)')
def delayed_move_mean(a, window_arr, out):
window_width = window_arr[0]
asum = 0.0
count = 0
for i in range(window_width):
asum += a[i]
count += 1
out[i] = asum / count
for i in range(window_width, len(a)):
asum += a[i] - a[i - window_width]
out[i] = asum / count
arr = np.arange(20, dtype=np.float64).reshape(2, 10)
print(arr)
print(dask.compute(delayed_move_mean(arr, 3)))
示例 10-8. 装饰器技巧
@guvectorize(['void(float64[:], intp[:], float64[:])'],
'(n),()->(n)')
def move_mean(a, window_arr, out):
window_width = window_arr[0]
asum = 0.0
count = 0
for i in range(window_width):
asum += a[i]
count += 1
out[i] = asum / count
for i in range(window_width, len(a)):
asum += a[i] - a[i - window_width]
out[i] = asum / count
arr = np.arange(20, dtype=np.float64).reshape(2, 10)
print(arr)
print(move_mean(arr, 3))
def wrapped_move_mean(*args):
return move_mean(*args)
a = dask.delayed(wrapped_move_mean)(arr, 3)
注意
示例 10-7 在本地模式下可以工作,但在扩展时不能工作。
GPU
像 Python 中的大多数任务一样,有许多不同的库可用于处理 GPU。 这些库中的许多支持 NVIDIA 的计算统一设备架构(CUDA),并试验性地支持 AMD 的新开放 HIP / Radeon Open Compute 模块(ROCm)接口。 NVIDIA 和 CUDA 是第一个出现的,并且比 AMD 的 Radeon Open Compute 模块采用得多,以至于 ROCm 主要集中于支持将 CUDA 软件移植到 ROCm 平台上。
我们不会深入探讨 Python GPU 库的世界,但您可能想查看Numba 的 GPU 支持、TensorFlow 的 GPU 支持和PyTorch 的 GPU 支持。
大多数具有某种形式 GPU 支持的库都需要编译大量非 Python 代码。 因此,通常最好使用 conda 安装这些库,因为 conda 通常具有更完整的二进制包装,允许您跳过编译步骤。
构建在 Dask 之上的 GPU 加速
扩展 Dask 的三个主要 CUDA 库是 cuDF(之前称为 dask-cudf)、BlazingSQL 和 cuML。² 目前这些库主要关注的是 NVIDIA GPU。
注意
Dask 目前没有任何库来支持与 OpenCL 或 HIP 的集成。 这并不妨碍您使用支持它们的库(如 TensorFlow)来使用 GPU,正如前面所示。
cuDF
cuDF是 Dask 的 DataFrame 库的 GPU 加速版本。 一些基准测试显示性能提升了 7 倍到 50 倍。 并非所有的 DataFrame 操作都会有相同的速度提升。 例如,如果您是逐行操作而不是矢量化操作,那么使用 cuDF 而不是 Dask 的 DataFrame 库时可能会导致性能较慢。 cuDF 支持您可能使用的大多数常见数据类型,但并非所有数据类型都支持。
注意
在内部,cuDF 经常将工作委托给 cuPY 库,但由于它是由 NVIDIA 员工创建的,并且他们的重点是支持 NVIDIA 硬件,因此 cuDF 不直接支持 ROCm。
BlazingSQL
BlazingSQL 使用 GPU 加速来提供超快的 SQL 查询。 BlazingSQL 在 cuDF 之上运行。
注意
虽然 BlazingSQL 是一个很棒的工具,但它的大部分文档都有问题。例如,在撰写本文时,主 README 中链接的所有示例都无法正确解析,而且文档站点完全处于离线状态。
cuStreamz
另一个 GPU 加速的流式处理库是 cuStreamz,它基本上是 Dask 流式处理和 cuDF 的组合;我们在附录 D 中对其进行了更详细的介绍。
释放加速器资源
在 GPU 上分配内存往往很慢,因此许多库会保留这些资源。在大多数情况下,如果 Python VM 退出,资源将被清理。最后的选择是使用 client.restart
弹出所有工作进程。在可能的情况下,手动管理资源是最好的选择,这取决于库。例如,cuPY 用户可以通过调用 free_all_blocks()
来释放所使用的块,如内存管理文档所述。
设计模式:CPU 回退
CPU 回退是指尝试使用加速器,如 GPU 或 TPU,如果加速器不可用,则回退到常规 CPU 代码路径。在大多数情况下,这是一个好的设计模式,因为加速器(如 GPU)可能很昂贵,并且可能不总是可用。然而,在某些情况下,CPU 和 GPU 性能之间的差异是如此之大,以至于回退到 CPU 很难在实际时间内成功;这在深度学习算法中最常发生。
面向对象编程和鸭子类型在这种设计模式下有些适合,因为只要两个类实现了您正在使用的相同接口的部分,您就可以交换它们。然而,就像在 Dask DataFrames 中替换 pandas DataFrames 一样,它并不完美,特别是在性能方面。
警告
在一个更好的世界里,我们可以提交一个请求 GPU 资源的任务,如果没有被调度,我们可以切换回 CPU-only 资源。不幸的是,Dask 的资源调度更接近于“尽力而为”,³ 因此我们可能被调度到没有我们请求的资源的节点上。
结论
专用加速器,如 GPU,可能会对您的工作流程产生很大影响。选择适合您工作流程的正确加速器很重要,有些工作流程不太适合加速。Dask 不会自动使用任何加速器,但有各种库可用于 GPU 计算。许多这些库创建时并没有考虑到共享计算的概念,因此要特别注意意外资源泄漏,特别是由于 GPU 资源往往更昂贵。
¹ 假设莎士比亚仍然活着,但事实并非如此。
² BlazingSQL 可能已接近其生命周期的尽头;已经有相当长一段时间没有提交更新,而且其网站看起来就像那些上世纪 90 年代的 GeoCities 网站一样简陋。
³ 这并没有像文档中描述的那样详细,因此可能在未来发生变化。
第十一章:用 Dask 进行机器学习
现在你已经了解了 Dask 的许多不同数据类型、计算模式、部署选项和库,我们准备开始机器学习。你会很快发现,使用 Dask 进行机器学习非常直观,因为它与许多其他流行的机器学习库运行在相同的 Python 环境中。Dask 的内置数据类型和分布式调度器大部分完成了繁重的工作,使得编写代码成为用户的愉快体验。¹
本章将主要使用 Dask-ML 库,这是 Dask 开源项目中得到强力支持的机器学习库,但我们也会突出显示其他库,如 XGBoost 和 scikit-learn。Dask-ML 库旨在在集群和本地运行。² Dask-ML 通过扩展许多常见的机器学习库提供了熟悉的接口。机器学习与我们迄今讨论的许多任务有所不同,因为它需要框架(这里是 Dask-ML)更密切地协调工作。在本章中,我们将展示如何在自己的程序中使用它,并提供一些技巧。
由于机器学习是如此广泛和多样化的学科,我们只能涵盖 Dask-ML 有用的一些情况。本章将讨论一些常见的工作模式,例如探索性数据分析、随机拆分、特征化、回归和深度学习推断,从实践者的角度来看,逐步了解 Dask。如果您没有看到您特定的库或用例被涵盖,仍然可能通过 Dask 加速,您应该查看Dask-ML 的 API 指南。然而,机器学习并非 Dask 的主要关注点,因此您可能需要使用其他工具,如 Ray。
并行化机器学习
许多机器学习工作负载在两个维度上面临扩展挑战:模型大小和数据大小。训练具有大特征或组件的模型(例如许多深度学习模型)通常会变得计算受限,其中训练、预测和评估模型变得缓慢和难以管理。另一方面,许多机器学习模型,甚至看似简单的模型如回归模型,通常会在处理大量训练数据集时超出单台机器的限制,从而在扩展挑战中变得内存受限。
在内存受限的工作负载上,我们已经涵盖的 Dask 的高级集合(如 Dask 数组、DataFrame 和 bag)与 Dask-ML 库结合,提供了本地扩展能力。对于计算受限的工作负载,Dask 通过 Dask-ML 和 Dask-joblib 等集成实现了训练的并行化。在使用 scikit-learn 时,Dask 可以管理集群范围的工作分配,使用 Dask-joblib。你可能会注意到,每个工作流需要不同的库,这是因为每个机器学习工具都使用自己的并行化方法,而 Dask 扩展了这些方法。
您可以将 Dask 与许多流行的机器学习库一起使用,包括 scikit-learn 和 XGBoost。您可能已经熟悉您喜爱的机器学习库内部的单机并行处理。Dask 将这些单机框架(如 Dask-joblib)扩展到通过网络连接的多台机器上。
使用 Dask-ML 的时机
Dask 在具有有限分布式可变状态(如大型模型权重)的并行任务中表现突出。Dask 通常用于对机器学习模型进行推断/预测,这比训练要简单。另一方面,训练模型通常需要更多的工作器间通信,例如模型权重更新和重复循环,有时每个训练周期的计算量可能不同。您可以在这两种用例中都使用 Dask,但是对于训练的采用和工具支持并不如推断广泛。
Dask 与常见的数据准备工具(包括 pandas、NumPy、PyTorch 和 TensorFlow)的集成使得构建推断管道变得更加容易。在像 Spark 这样的基于 JVM 的工具中,使用这些库会增加更多的开销。
Dask 的另一个很好的用例是在训练之前进行特征工程和绘制大型数据集。Dask 的预处理函数通常使用与 scikit-learn 相同的签名和方式,同时跨多台机器分布工作。类似地,对于绘图和可视化,Dask 能够生成一个大型数据集的美观图表,超出了 matplotlib/seaborn 的常规限制。
对于更复杂的机器学习和深度学习工作,一些用户选择单独生成 PyTorch 或 TensorFlow 模型,然后使用生成的模型进行基于 Dask 的推断工作负载。这样可以使 Dask 端的工作负载具有令人尴尬的并行性。另外,一些用户选择使用延迟模式将训练数据写入 Dask DataFrame,然后将其馈送到 Keras 或 Torch 中。请注意,这样做需要一定的中等工作量。
如前面章节所述,Dask 项目仍处于早期阶段,其中一些库仍在进行中并带有免责声明。我们格外小心地验证了 Dask-ML 库中使用的大部分数值方法,以确保逻辑和数学是正确的,并按预期工作。然而,一些依赖库有警告称其尚未准备好供主流使用,特别是涉及 GPU 感知工作负载和大规模分布式工作负载时。我们预计随着社区的增长和用户贡献反馈,这些问题将得到解决。
开始使用 Dask-ML 和 XGBoost
Dask-ML 是 Dask 的官方支持的机器学习库。在这里,我们将介绍 Dask-ML API 提供的功能,以及它如何将 Dask、pandas 和 scikit-learn 集成到其功能中,以及 Dask 和其 scikit-learn 等效物之间的一些区别。此外,我们还将详细讲解几个 XGBoost 梯度提升集成案例。我们将主要使用之前用过的纽约市黄色出租车数据进行示例演示。您可以直接从纽约市网站访问数据集。
特征工程
就像任何优秀的数据科学工作流一样,我们从清理、应用缩放器和转换开始。Dask-ML 提供了大部分来自 scikit-learn 的预处理 API 的即插即用替代品,包括 StandardScaler
、PolynomialFeatures
、MinMaxScaler
等。
您可以将多个列传递给转换器,每个列都将被归一化,最终生成一个延迟的 Dask DataFrame,您应该调用 compute
方法来执行计算。
在 示例 11-1 中,我们对行程距离(单位为英里)和总金额(单位为美元)进行了缩放,生成它们各自的缩放变量。这是我们在第四章中进行的探索性数据分析的延续。
示例 11-1. 使用 StandardScaler
对 Dask DataFrame 进行预处理
from dask_ml.preprocessing import StandardScaler
import dask.array as da
import numpy as np
df = dd.read_parquet(url)
trip_dist_df = df[["trip_distance", "total_amount"]]
scaler = StandardScaler()
scaler.fit(trip_dist_df)
trip_dist_df_scaled = scaler.transform(trip_dist_df)
trip_dist_df_scaled.head()
对于分类变量,在 Dask-ML 中虽然有 OneHotEncoder
,但其效率和一对一替代程度都不及其 scikit-learn 的等效物。因此,我们建议使用 Categorizer
对分类 dtype 进行编码。³
示例 11-2 展示了如何对特定列进行分类,同时保留现有的 DataFrame。我们选择 payment_type
,它最初被编码为整数,但实际上是一个四类别分类变量。我们调用 Dask-ML 的 Categorizer
,同时使用 pandas 的 CategoricalDtype
给出类型提示。虽然 Dask 具有类型推断能力(例如,它可以自动推断类型),但在程序中明确指定类型总是更好的做法。
示例 11-2. 使用 Dask-ML 对 Dask DataFrame 进行分类变量预处理
from dask_ml.preprocessing import Categorizer
from pandas.api.types import CategoricalDtype
payment_type_amt_df = df[["payment_type", "total_amount"]]
cat = Categorizer(categories={"payment_type": CategoricalDtype([1, 2, 3, 4])})
categorized_df = cat.fit_transform(payment_type_amt_df)
categorized_df.dtypes
payment_type_amt_df.head()
或者,您可以选择使用 Dask DataFrame 的内置分类器。虽然 pandas 对 Object 和 String 作为分类数据类型是宽容的,但除非首先将这些列读取为分类变量,否则 Dask 将拒绝这些列。有两种方法可以做到这一点:在读取数据时将列声明为分类,使用dtype={col: categorical}
,或在调用get_dummies
之前转换,使用df.categorize(“col1”)
。这里的理由是 Dask 是惰性评估的,不能在没有看到完整唯一值列表的情况下创建列的虚拟变量。调用.categorize()
很方便,并允许动态处理额外的类别,但请记住,它确实需要先扫描整个列以获取类别,然后再扫描转换列。因此,如果您已经知道类别并且它们不会改变,您应该直接调用DummyEncoder
。
Example 11-3 一次对多列进行分类。在调用execute
之前,没有任何实质性的东西被实现,因此你可以一次链式地连接许多这样的预处理步骤。
Example 11-3. 使用 Dask DataFrame 内置的分类变量作为预处理
train = train.categorize("VendorID")
train = train.categorize("passenger_count")
train = train.categorize("store_and_fwd_flag")
test = test.categorize("VendorID")
test = test.categorize("passenger_count")
test = test.categorize("store_and_fwd_flag")
DummyEncoder
是 Dask-ML 中类似于 scikit-learn 的OneHotEncoder
,它将变量转换为 uint8,即一个 8 位无符号整数,更节省内存。
再次,有一个 Dask DataFrame 函数可以给您类似的结果。Example 11-4 在分类列上演示了这一点,而 Example 11-5 则预处理了日期时间。日期时间可能会带来一些棘手的问题。在这种情况下,Python 原生反序列化日期时间。请记住,始终在转换日期时间之前检查并应用必要的转换。
Example 11-4. 使用 Dask DataFrame 内置的虚拟变量作为哑变量的预处理
from dask_ml.preprocessing import DummyEncoder
dummy = DummyEncoder()
dummified_df = dummy.fit_transform(categorized_df)
dummified_df.dtypes
dummified_df.head()
Example 11-5. 使用 Dask DataFrame 内置的虚拟变量作为日期时间的预处理
train['Hour'] = train['tpep_pickup_datetime'].dt.hour
test['Hour'] = test['tpep_pickup_datetime'].dt.hour
train['dayofweek'] = train['tpep_pickup_datetime'].dt.dayofweek
test['dayofweek'] = test['tpep_pickup_datetime'].dt.dayofweek
train = train.categorize("dayofweek")
test = test.categorize("dayofweek")
dom_train = dd.get_dummies(
train,
columns=['dayofweek'],
prefix='dom',
prefix_sep='_')
dom_test = dd.get_dummies(
test,
columns=['dayofweek'],
prefix='dom',
prefix_sep='_')
hour_train = dd.get_dummies(
train,
columns=['dayofweek'],
prefix='h',
prefix_sep='_')
hour_test = dd.get_dummies(
test,
columns=['dayofweek'],
prefix='h',
prefix_sep='_')
dow_train = dd.get_dummies(
train,
columns=['dayofweek'],
prefix='dow',
prefix_sep='_')
dow_test = dd.get_dummies(
test,
columns=['dayofweek'],
prefix='dow',
prefix_sep='_')
Dask-ML 的train_test_split
方法比 Dask DataFrames 版本更灵活。两者都支持分区感知,我们使用它们而不是 scikit-learn 的等价物。scikit-learn 的train_test_split
可以在此处调用,但它不具备分区感知性,可能导致大量数据在工作节点之间移动,而 Dask 的实现则会在每个分区上分割训练集和测试集,避免洗牌(参见 Example 11-6)。
Example 11-6. Dask DataFrame 伪随机分割
from dask_ml.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(
df['trip_distance'], df['total_amount'])
每个分区块的随机分割的副作用是,整个 DataFrame 的随机行为不能保证是均匀的。如果你怀疑某些分区可能存在偏差,你应该计算、重新分配,然后洗牌分割。
模型选择和训练
很多 scikit-learn 的模型选择相关功能,包括交叉验证、超参数搜索、聚类、回归、填充以及评分方法,都已经作为一个可替换组件移植到 Dask 中。有几个显著的改进使得这些功能比简单的并行计算架构更高效,这些改进利用了 Dask 的任务图形视图。
大多数基于回归的模型已经为 Dask 实现,并且可以作为 scikit-learn 的替代品使用。⁴ 许多 scikit-learn 用户熟悉使用 .reshape()
对 pandas 进行操作,需要将 pandas DataFrame 转换为二维数组以便 scikit-learn 使用。对于一些 Dask-ML 的函数,你仍然需要调用 ddf.to_dask_array()
将 DataFrame 转换为数组以进行训练。最近,一些 Dask-ML 已经改进,可以直接在 Dask DataFrames 上工作,但并非所有库都支持。
示例 11-7 展示了使用 Dask-ML 进行直观的多变量线性回归。假设您希望在两个预测列和一个输出列上构建回归模型。您将应用 .to_array()
将数据类型转换为 Dask 数组,然后将它们传递给 Dask-ML 的 LinearRegression
实现。请注意,我们需要明确指定分块大小,这是因为 Dask-ML 在线性模型的底层实现中并未完全能够从前一步骤中推断出分块大小。我们还特意使用 scikit-learn 的评分库,而不是 Dask-ML。我们注意到 Dask-ML 在处理分块大小时存在实施问题。⁵ 幸运的是,在这一点上,这个计算是一个简化步骤,它可以在没有任何特定于 Dask 的逻辑的情况下工作。⁶
示例 11-7. 使用 Dask-ML 进行线性回归
from dask_ml.linear_model import LinearRegression
from dask_ml.model_selection import train_test_split
regr_df = df[['trip_distance', 'total_amount']].dropna()
regr_X = regr_df[['trip_distance']]
regr_y = regr_df[['total_amount']]
X_train, X_test, y_train, y_test = train_test_split(
regr_X, regr_y)
X_train = X_train.to_dask_array(lengths=[100]).compute()
X_test = X_test.to_dask_array(lengths=[100]).compute()
y_train = y_train.to_dask_array(lengths=[100]).compute()
y_test = y_test.to_dask_array(lengths=[100]).compute()
reg = LinearRegression()
reg.fit(X_train, y_train)
y_pred = reg.predict(X_test)
r2_score(y_test, y_pred)
请注意,scikit-learn 和 Dask-ML 模型的函数参数是相同的,但是目前不支持的一些功能。例如,LogisticRegression
在 Dask-ML 中是可用的,但是不支持多类别求解器,这意味着 Dask-ML 中尚未实现多类别求解器的确切等效。因此,如果您想要使用 multinomial loss solver newton-cg 或 newton-cholesky,可能不会起作用。对于大多数 LogisticRegression
的用途,缺省的 liblinear 求解器会起作用。在实践中,此限制仅适用于更为专业和高级的用例。
对于超参数搜索,Dask-ML 具有与 scikit-learn 类似的 GridSearchCV
用于参数值的详尽搜索,以及 RandomizedSearchCV
用于从列表中随机尝试超参数。如果数据和结果模型不需要太多的缩放,可以直接运行这些功能,与 scikit-learn 的变体类似。
交叉验证和超参数调优通常是一个昂贵的过程,即使在较小的数据集上也是如此,任何运行过 scikit-learn 交叉验证的人都会证明。Dask 用户通常处理足够大的数据集,使用详尽搜索算法是不可行的。作为替代方案,Dask-ML 实现了几种额外的自适应算法和基于超带的方法,这些方法通过稳健的数学基础更快地接近调整参数。⁷ HyperbandSearchCV
方法的作者要求引用使用。⁸
当没有 Dask-ML 等效时。
如果在 scikit-learn 或其他数据科学库中存在但不在 Dask-ML 中存在的函数,则可以编写所需代码的分布式版本。毕竟,Dask-ML 可以被视为 scikit-learn 的便利包装器。
Example 11-8 使用 scikit-learn 的学习函数 SGDRegressor
和 LinearRegression
,并使用 dask.delayed
将延迟功能包装在该方法周围。您可以对您想要并行化的任何代码片段执行此操作。
Example 11-8. 使用 Dask-ML 进行线性回归
from sklearn.linear_model import LinearRegression as ScikitLinearRegression
from sklearn.linear_model import SGDRegressor as ScikitSGDRegressor
estimators = [ScikitLinearRegression(), ScikitSGDRegressor()]
run_tasks = [dask.delayed(estimator.fit)(X_train, y_train)
for estimator in estimators]
run_tasks
使用 Dask 的 joblib。
或者,您可以使用 scikit-learn 与 joblib(参见 Example 11-9),这是一个可以将任何 Python 函数作为流水线步骤在单台机器上计算的包。Joblib 在许多不依赖于彼此的并行计算中表现良好。在这种情况下,拥有单台机器上的数百个核心将非常有帮助。虽然典型的笔记本电脑没有数百个核心,但使用它所拥有的四个核心仍然是有益的。使用 Dask 版本的 joblib,您可以使用来自多台机器的核心。这对于在单台机器上计算受限的 ML 工作负载是有效的。
Example 11-9. 使用 joblib 进行计算并行化
from dask.distributed import Client
from joblib import parallel_backend
client = Client('127.0.0.1:8786')
X, y = load_my_data()
net = get_that_net()
gs = GridSearchCV(
net,
param_grid={'lr': [0.01, 0.03]},
scoring='accuracy',
)
XGBClassifier()
with parallel_backend('dask'):
gs.fit(X, y)
print(gs.cv_results_)
使用 Dask 的 XGBoost。
XGBoost 是一个流行的 Python 梯度增强库,用于并行树增强。众所周知的梯度增强方法包括自举聚合(bagging)。各种梯度增强方法已在大型强子对撞机的高能物理数据分析中使用,用于训练深度神经网络以确认发现希格斯玻色子。梯度增强方法目前在地质或气候研究等科学领域中被广泛使用。考虑到其重要性,我们发现在 Dask-ML 上的 XGBoost 是一个良好实现的库,可以为用户准备就绪。
Dask-ML 具有内置支持以与 Dask 数组和 DataFrame 一起使用 XGBoost。通过在 Dask-ML 中使用 XGBClassifiers,您将在分布式模式下设置 XGBoost,它可以与您的 Dask 集群一起使用。在这种情况下,XGBoost 的主进程位于 Dask 调度程序中,XGBoost 的工作进程将位于 Dask 的工作进程上。数据分布使用 Dask DataFrame 处理,拆分为 pandas DataFrame,并在同一台机器上的 Dask 工作进程和 XGBoost 工作进程之间通信。
XGBoost 使用 DMatrix
(数据矩阵)作为其标准数据格式。XGBoost 有内置的 Dask 兼容的 DMatrix
,可以接受 Dask 数组和 Dask DataFrame。一旦设置了 Dask 环境,梯度提升器的使用就像您期望的那样。像往常一样指定学习率、线程和目标函数。示例 11-10 使用 Dask CUDA 集群,并运行标准的梯度提升器训练。
示例 11-10. 使用 Dask-ML 进行梯度提升树
import xgboost as xgb
from dask_cuda import LocalCUDACluster
from dask.distributed import Client
n_workers = 4
cluster = LocalCUDACluster(n_workers)
client = Client(cluster)
dtrain = xgb.dask.DaskDMatrix(client, X_train, y_train)
booster = xgb.dask.train(
client,
{"booster": "gbtree", "verbosity": 2, "nthread": 4, "eta": 0.01, gamma=0,
"max_depth": 5, "tree_method": "auto", "objective": "reg:squarederror"},
dtrain,
num_boost_round=4,
evals=[(dtrain, "train")])
在 示例 11-11 中,我们进行了简单的训练并绘制了特征重要性图。注意,当我们定义 DMatrix
时,我们明确指定了标签,标签名称来自于 Dask DataFrame 到 DMatrix
。
示例 11-11. 使用 XGBoost 库的 Dask-ML
import xgboost as xgb
dtrain = xgb.DMatrix(X_train, label=y_train, feature_names=X_train.columns)
dvalid = xgb.DMatrix(X_test, label=y_test, feature_names=X_test.columns)
watchlist = [(dtrain, 'train'), (dvalid, 'valid')]
xgb_pars = {
'min_child_weight': 1,
'eta': 0.5,
'colsample_bytree': 0.9,
'max_depth': 6,
'subsample': 0.9,
'lambda': 1.,
'nthread': -1,
'booster': 'gbtree',
'silent': 1,
'eval_metric': 'rmse',
'objective': 'reg:linear'}
model = xgb.train(xgb_pars, dtrain, 10, watchlist, early_stopping_rounds=2,
maximize=False, verbose_eval=1)
print('Modeling RMSLE %.5f' % model.best_score)
xgb.plot_importance(model, max_num_features=28, height=0.7)
pred = model.predict(dtest)
pred = np.exp(pred) - 1
将之前的示例结合起来,您现在可以编写一个函数,该函数可以适配模型、提供早停参数,并使用 Dask 进行 XGBoost 预测(见 示例 11-12)。这些将在您的主客户端代码中调用。
示例 11-12. 使用 Dask XGBoost 库进行梯度提升树训练和推断
import xgboost as xgb
from dask_cuda import LocalCUDACluster
from dask.distributed import Client
n_workers = 4
cluster = LocalCUDACluster(n_workers)
client = Client(cluster)
def fit_model(client, X, y, X_valid, y_valid,
early_stopping_rounds=5) -> xgb.Booster:
Xy_valid = dxgb.DaskDMatrix(client, X_valid, y_valid)
# train the model
booster = xgb.dask.train(
client,
{"booster": "gbtree", "verbosity": 2, "nthread": 4, "eta": 0.01, gamma=0,
"max_depth": 5, "tree_method": "gpu_hist", "objective": "reg:squarederror"},
dtrain,
num_boost_round=500,
early_stopping_rounds=early_stopping_rounds,
evals=[(dtrain, "train")])["booster"]
return booster
def predict(client, model, X):
predictions = xgb.predict(client, model, X)
assert isinstance(predictions, dd.Series)
return predictions
在 Dask-SQL 中使用 ML 模型
另一个较新的添加是 Dask-SQL 库,它提供了一个便捷的包装器,用于简化 ML 模型训练工作负载。 示例 11-13 加载与之前相同的 NYC 黄色出租车数据作为 Dask DataFrame,然后将视图注册到 Dask-SQL 上下文中。
示例 11-13. 将数据集注册到 Dask-SQL 中
import dask.dataframe as dd
import dask.datasets
from dask_sql import Context
# read dataset
taxi_df = dd.read_csv('./data/taxi_train_subset.csv')
taxi_test = dd.read_csv('./data/taxi_test.csv')
# create a context to register tables
c = Context()
c.create_table("taxi_test", taxi_test)
c.create_table("taxicab", taxi_df)
Dask-SQL 实现了类似 BigQuery ML 的 ML SQL 语言,使您能够简单地定义模型,将训练数据定义为 SQL select 语句,然后在不同的 select 语句上运行推断。
您可以使用我们讨论过的大多数 ML 模型定义模型,并在后台运行 scikit-learn ML 模型。在 示例 11-14 中,我们使用 Dask-SQL 训练了之前训练过的 LinearRegression
模型。我们首先定义模型,告诉它使用 scikit-learn 的 LinearRegression
和目标列。然后,我们使用必要的列传递训练数据。您可以使用 DESCRIBE
语句检查训练的模型;然后您可以在 FROM PREDICT
语句中看到模型如何在另一个 SQL 定义的数据集上运行推断。
示例 11-14. 在 Dask-SQL 上定义、训练和预测线性回归模型
import dask.dataframe as dd
import dask.datasets
from dask_sql import Context
c = Context()
# define model
c.sql(
"""
CREATE MODEL fare_linreg_model WITH (
model_class = 'LinearRegression',
wrap_predict = True,
target_column = 'fare_amount'
) AS (
SELECT passenger_count, fare_amount
FROM taxicab
LIMIT 1000
)
"""
)
# describe model
c.sql(
"""
DESCRIBE MODEL fare_linreg_model
"""
).compute()
# run inference
c.sql(
"""
SELECT
*
FROM PREDICT(MODEL fare_linreg_model,
SELECT * FROM taxi_test
)
"""
).compute()
同样地,如 示例 11-15 所示,您可以使用 Dask-ML 库运行分类模型,类似于我们之前讨论过的 XGBoost 模型。
示例 11-15. 在 Dask-SQL 上使用 XGBoost 定义、训练和预测分类器
import dask.dataframe as dd
import dask.datasets
from dask_sql import Context
c = Context()
# define model
c.sql(
"""
CREATE MODEL classify_faretype WITH (
model_class = 'XGBClassifier',
target_column = 'fare_type'
) AS (
SELECT airport_surcharge, passenger_count, fare_type
FROM taxicab
LIMIT 1000
)
"""
)
# describe model
c.sql(
"""
DESCRIBE MODEL classify_faretype
"""
).compute()
# run inference
c.sql(
"""
SELECT
*
FROM PREDICT(MODEL classify_faretype,
SELECT airport_surcharge, passenger_count, FROM taxi_test
)
"""
).compute()
推断和部署
无论您选择使用哪些库来训练和验证您的模型(可以使用一些 Dask-ML 库,或完全不使用 Dask 训练),在使用 Dask 进行模型推断部署时,需要考虑以下一些事项。
手动分发数据和模型
当将数据和预训练模型加载到 Dask 工作节点时,dask.delayed
是主要工具(参见 示例 11-16)。在分发数据时,您应选择使用 Dask 的集合:数组和 DataFrame。正如您从 第四章 中记得的那样,每个 Dask DataFrame 由一个 pandas DataFrame 组成。这非常有用,因为您可以编写一个方法,该方法接受每个较小的 DataFrame,并返回计算输出。还可以使用 Dask DataFrame 的 map_partitions
函数为每个分区提供自定义函数和任务。
如果您正在读取大型数据集,请记得使用延迟表示法,以延迟实体化并避免过早读取。
小贴士
map_partitions
是一种逐行操作,旨在适合序列化代码并封送到工作节点。您可以定义一个处理推理的自定义类来调用,但需要调用静态方法,而不是依赖实例的方法。我们在 第四章 进一步讨论了这一点。
示例 11-16. 在 Dask 工作节点上加载大文件
from skimage.io import imread
from skimage.io.collection import alphanumeric_key
from dask import delayed
import dask.array as da
import os
root, dirs, filenames = os.walk(dataset_dir)
# sample first file
imread(filenames[0])
@dask.delayed
def lazy_reader(file):
return imread(file)
# we have a bunch of delayed readers attached to the files
lazy_arrays = [lazy_reader(file) for file in filenames]
# read individual files from reader into a dask array
# particularly useful if each image is a large file like DICOM radiographs
# mammography dicom tends to be extremely large
dask_arrays = [
da.from_delayed(delayed_reader, shape=(4608, 5200,), dtype=np.float32)
for delayed_reader in lazy_arrays
]
使用 Dask 进行大规模推理
当使用 Dask 进行规模推理时,您会将训练好的模型分发到每个工作节点,然后将 Dask 集合(DataFrame 或数组)分发到这些分区,以便一次处理集合的一部分,从而并行化工作流程。这种策略在简单的推理部署中效果良好。我们将讨论其中一种实现方式:手动定义工作流程,使用 map_partitions
,然后用 PyTorch 或 Keras/TensorFlow 模型包装现有函数。对于基于 PyTorch 的模型,您可以使用 Skorch 将模型包装起来,从而使其能够与 Dask-ML API 一起使用。对于 TensorFlow 模型,您可以使用 SciKeras 创建一个与 scikit-learn 兼容的模型,这样就可以用于 Dask-ML。对于 PyTorch,SaturnCloud 的 dask-pytorch-ddp 库目前是最广泛使用的。至于 Keras 和 TensorFlow,请注意,虽然可以做到,但 TensorFlow 不喜欢一些线程被移动到其他工作节点。
部署推理最通用的方式是使用 Dask DataFrame 的 map_partitions
(参见 示例 11-17)。您可以使用自定义推理函数,在每行上运行该函数,数据映射到每个工作节点的分区。
示例 11-17. 使用 Dask DataFrame 进行分布式推理
import dask.dataframe as dd
import dask.bag as db
def rowwise_operation(row, arg *):
# row-wise compute
return result
def partition_operation(df):
# partition wise logic
result = df[col1].apply(rowwise_operation)
return result
ddf = dd.read_csv(“metadata_of_files”)
results = ddf.map_partitions(partition_operation)
results.compute()
# An alternate way, but note the .apply() here becomes a pandas apply, not
# Dask .apply(), and you must define axis = 1
ddf.map_partitions(
lambda partition: partition.apply(
lambda row: rowwise_operation(row), axis=1), meta=(
'ddf', object))
Dask 提供比其他可扩展库更多的灵活性,特别是在并行行为方面。在前面的示例中,我们定义了一个逐行工作的函数,然后将该函数提供给分区逻辑,每个分区在整个 DataFrame 上运行。我们可以将其作为样板来定义更精细的批处理函数(见示例 11-18)。请记住,在逐行函数中定义的行为应该没有副作用,即,应避免突变函数的输入,这是 Dask 分布式延迟计算的一般最佳实践。此外,正如前面示例中的注释所述,如果在分区式 lambda 内执行.apply()
,这会调用 pandas 的.apply()
。在 Pandas 中,.apply()
默认为axis = 0
,如果你想要其他方式,应记得指定axis = 1
。
示例 11-18. 使用 Dask DataFrame 进行分布式推断
def handle_batch(batch, conn, nlp_model):
# run_inference_here.
conn.commit()
def handle_partition(df):
worker = get_worker()
conn = connect_to_db()
try:
nlp_model = worker.roberta_model
except BaseException:
nlp_model = load_model()
worker.nlp_model = nlp_model
result, batch = [], []
for _, row in part.iterrows():
if len(batch) % batch_size == 0 and len(batch) > 0:
batch_results = handle_batch(batch, conn, nlp_model)
result.append(batch_results)
batch = []
batch.append((row.doc_id, row.sent_id, row.utterance))
if len(batch) > 0:
batch_results = handle_batch(batch, conn, nlp_model)
result.append(batch_results)
conn.close()
return result
ddf = dd.read_csv("metadata.csv”)
results = ddf.map_partitions(handle_partition)
results.compute()
结论
在本章中,您已经学习了如何使用 Dask 的构建模块来编写数据科学和 ML 工作流程,将核心 Dask 库与您可能熟悉的其他 ML 库结合起来,以实现您所需的任务。您还学习了如何使用 Dask 来扩展计算和内存密集型 ML 工作负载。
Dask-ML 几乎提供了与 scikit-learn 功能相当的库,通常使用 Dask 带来的任务和数据并行意识调用 scikit-learn。Dask-ML 由社区积极开发,并将进一步增加用例和示例。查阅 Dask 文档以获取最新更新。
此外,您已经学会了如何通过使用 joblib 进行计算密集型工作负载的并行化 ML 训练方法,并使用批处理操作处理数据密集型工作负载,以便自己编写任何定制实现。
最后,你已经学习了 Dask-SQL 的用例及其 SQL ML 语句,在模型创建、超参数调整和推断中提供高级抽象。
由于 ML 可能需要大量计算和内存,因此在正确配置的集群上部署您的 ML 工作并密切监视进度和输出非常重要。我们将在下一章中介绍部署、分析和故障排除。
¹ 如果你认为编写数据工程代码是“有趣”的人。
² 这对于非批量推断尤为重要,可以很大程度上提高使用相同代码的便利性。
³ 由于性能原因,在撰写本文时,Dask 的OneHotEncoder
调用了 pandas 的get_dummies
方法,这比 scikit-learn 的OneHotEncoder
实现较慢。另一方面,Categorizer
使用了 Dask DataFrame 的聚合方法,以高效地扫描类别。
⁴ Dask-ML 中的大多数线性模型使用了为 Dask 实现的广义线性模型库的基本实现。我们已经验证了代码的数学正确性,但这个库的作者尚未认可其在主流应用中的使用。
⁵ Dask-ML 版本 2023.3.24;部分广义线性模型依赖于 dask-glm 0.1.0。
⁶ 因为这是一个简单的归约操作,我们不需要保留之前步骤中的分块。
⁷ Dask-ML 的官方文档提供了有关实现的自适应和近似交叉验证方法以及使用案例的更多信息。
⁸ 他们在文档中指出,如果使用此方法,应引用以下论文:S. Sievert, T. Augspurger, 和 M. Rocklin, “Better and Faster Hyperparameter Optimization with Dask,” Proceedings of the 18th Python in Science Conference (2019), doi.org/10.25080/Majora-7ddc1dd1-011
.
第十二章:将 Dask 投入生产:笔记本、部署、调整和监控
在这一章中,我们将捆绑我们认为对您从笔记本电脑转入生产环境至关重要的大部分内容。笔记本和部署是相关联的,因为 Dask 的笔记本界面极大简化了使用其分布式部署的许多方面。虽然您不必使用笔记本来访问 Dask,在许多情况下笔记本存在严重缺点,但对于交互式用例,很难击败这种权衡。交互式/探索性工作往往会成为永久的关键工作流程,我们将介绍将探索性工作转变为生产部署所需的步骤。
您可以以多种方式部署 Dask,从在其他分布式计算引擎(如 Ray)上运行到部署在 YARN 或原始机器集合上。一旦部署了您的 Dask 作业,您可能需要调整它,以避免将公司的整个 AWS 预算用于一个作业。最后,在离开一个作业之前,您需要设置监控——这样您就会知道它何时出现故障。
注意
如果您只是想学习如何在笔记本中使用 Dask,可以直接跳到该部分。如果您想了解更多关于部署 Dask 的信息,祝贺您并对超出单台计算机处理能力的规模感到遗憾。
在本章中,我们将介绍一些(但不是全部)Dask 的部署选项及其权衡。您将学习如何将笔记本集成到最常见的部署环境中。您将看到如何使用这些笔记本来跟踪您的 Dask 任务的进度,并在远程运行时访问 Dask UI。最后,我们将介绍一些部署您计划任务的选项,这样您就可以放心度假,而不必每天找人按下笔记本的运行按钮。
注意
本章涵盖了 Dask 的分布式部署,但如果您的 Dask 程序在本地模式下运行良好,不必为了部署集群而感到需要。¹
在部署选项中考虑的因素
在选择如何部署 Dask 时,有许多不同的因素需要考虑,但通常最重要的因素是您的组织已经在使用哪些工具。大多数部署选项都与不同类型的集群管理器(CMs)相关联。CMs 管理一组计算机,并在用户和作业之间提供一些隔离。隔离可能非常重要——例如,如果一个用户吃掉了所有的糖果(或者 CPU),那么另一个用户就没有糖果了。大多数集群管理器提供 CPU 和内存隔离,有些还隔离其他资源(如磁盘和 GPU)。大多数云平台(AWS、GCP 等)都提供 Kubernetes 和 YARN 集群管理器,可以动态调整节点的数量。Dask 不需要 CM 即可运行,但如果没有 CM,将无法使用自动扩展和其他重要功能。
在选择部署机制时,无论是否使用配置管理器(CM),需要考虑的一些重要因素包括扩展能力、多租户、依赖管理,以及部署方法是否支持异构工作节点。
在许多情况下,扩展能力(或动态扩展)非常重要,因为计算机是需要花钱的。对于利用加速器(如 GPU)的工作负载来说,异构或混合工作节点类型非常重要,这样非加速工作可以被调度到成本较低的节点上。支持异构工作节点与动态扩展很搭配,因为工作节点可以被替换。
多租户可以减少不能扩展的系统中浪费的计算资源。
依赖管理允许您在运行时或预先控制工作节点上的软件,这在 Dask 中非常关键;如果工作节点和客户端没有相同的库,您的代码可能无法正常运行。此外,有些库在运行时安装可能很慢,因此能够预先安装或共享环境对某些用例尤其有益,特别是在深度学习领域。
表 12-1 比较了一些 Dask 的部署选项。
表 12-1. 部署选项比较
部署方法 | 动态扩展 | 推荐用例^(a) | 依赖管理 | 在笔记本内部部署^(b) | 混合工作节点类型 |
---|---|---|---|---|---|
localhost | 否 | 测试,独立开发,仅 GPU 加速 | 运行时或预安装 | 是 | 否 |
ssh | 否 | 单独实验室,测试,但通常不推荐(使用 k8s 替代) | 仅运行时 | 是 | 是(手动) |
Slurm + GW | 是 | 现有的高性能计算/Slurm 环境 | 是(运行时或预安装) | 单独的项目 | 各异 |
Dask “Cloud” | 是 | 不推荐;在云提供商上使用 Dask + K8s 或 YARN | 仅运行时 | 中等难度^(c) | 否 |
Dask + K8s | 是 | 云环境,现有的 K8s 部署 | 运行时或预安装(但需要更多工作) | 单独的项目,中等难度 | 是 |
Dask + YARN | 是 | 现有的大数据部署 | 运行时或预安装(但需要更多工作) | 自 2019 年以来未更新的单独项目 | 是 |
Dask + Ray + [CM] | 取决于 CM | 现有的 Ray 部署,多工具(TF 等),或者 actor 系统 | 取决于 CM(至少总是运行时) | 取决于 CM | 是 |
Coiled | 是 | 新的云部署 | 是,包括魔术“自动同步” | 否 | 是 |
^(a) 这主要基于我们的经验,可能偏向于大公司和学术环境。请随意做出您自己的决定。^(b) 有一些解决方案。^(c) 有些大型通用云提供商比其他更容易。Mika 自己的经验认为,Google Cloud 最容易,Amazon 居中,Azure 最难处理。Google Cloud 在使用 Dask 与 RAPIDS NVIDIA 架构和工作流程方面有良好的工作指南。同样,Amazon Web Services 在多个 Amazon Elastic Compute Cloud (EC2) 实例上运行 Dask workers 和挂载 S3 存储桶的文档都很好。Azure 需要做一些工作才能使 worker 配置工作良好,主要是由于其环境和用户配置工作流程与 AWS 或 GCP 有所不同。 |
在 Kubernetes 部署 Dask
有两种主要的方式可以在 Kubernetes 上部署 Dask:² KubeCluster 和 HelmCluster。Helm 是管理 Kubernetes 上部署的流行工具,部署在 Helm 图表中指定。由于 Helm 是管理 Kubernetes 上部署的新推荐方式,我们将在这里涵盖这一点。
Helm 文档 提供了关于不同安装 Helm 方式的优秀起始点,但是对于那些着急的人,curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
就可以搞定了。³
注意
Dask 在 Kubernetes 上的 Helm 图表部署了所谓的operator。当前,安装 operators 需要安装自定义资源定义(CRDs)的能力,并且可能需要管理员权限。如果你无法获取权限(或者有权限的人),你仍然可以使用“vanilla” or “classic” deployment mode。
由于 GPU 资源昂贵,通常希望只分配所需数量的资源。一些集群管理器接口,包括 Dask 的 Kubernetes 插件,允许您配置多种类型的 workers,以便 Dask 只在需要时分配 GPU workers。在我们的 Kubernetes 集群上,我们部署 Dask operator 如 Example 12-1 所示。
Example 12-1. 使用 Helm 部署 Dask operator
# Add the repo
helm repo add dask https://helm.dask.org
helm repo update
# Install the operator; you will use this to create clusters
helm install --create-namespace -n \
dask-operator --generate-name dask/dask-kubernetes-operator
现在你可以通过创建 YAML 文件(可能不是你最喜欢的方式)或者使用KubeCluster
API 来使用 Dask operator,如 Example 12-2 所示,在这里我们创建一个集群,然后添加额外的 worker 类型,允许 Dask 创建两种不同类型的 workers。⁴
Example 12-2. 使用 Dask operator
from dask_kubernetes.operator import KubeCluster
cluster = KubeCluster(name='simple',
n_workers=1,
resources={
"requests": {"memory": "16Gi"},
"limits": {"memory": "16Gi"}
})
cluster.add_worker_group(name="highmem",
n_workers=0,
resources={
"requests": {"memory": "64Gi"},
"limits": {"memory": "64Gi"}
})
cluster.add_worker_group(name="gpu",
n_workers=0,
resources={
"requests": {"nvidia.com/gpu": "1"},
"limits": {"nvidia.com/gpu": "1"}
})
# Now you can scale these worker groups up and down as needed
cluster.scale("gpu", 5, worker_group="gpu")
# Fancy machine learning logic
cluster.scale("gpu", , worker_group="gpu")
# Or just auto-scale
cluster.adapt(minimum=1, maximum=10)
2020 年,Dask 添加了一个DaskHub Helm 图表,它将 JupyterHub 的部署与 Dask Gateway 结合在一起。
Dask on Ray
将 Dask 部署到 Ray 上与所有其他选项略有不同,因为它不仅改变了 Dask 工作节点和任务的调度方式,还改变了Dask 对象的存储方式。这可以减少需要存储的同一对象的副本数量,从而更有效地利用集群内存。
如果您已经有一个可用的 Ray 部署,启用 Dask 可能会非常简单,就像在示例 12-3 中所示的那样。
示例 12-3. 在 Ray 上运行 Dask
import dask
enable_dask_on_ray()
ddf_students = ray.data.dataset.Dataset.to_dask(ray_dataset)
ddf_students.head()
disable_dask_on_ray()
然而,如果您没有现有的 Ray 集群,您仍然需要在某处部署 Ray,并考虑与 Dask 相同的考虑因素。部署 Ray 超出了本书的范围。Ray 的生产指南以及Scaling Python with Ray中有有关在 Ray 上部署的详细信息。
在 YARN 上的 Dask
YARN 是来自大数据领域的流行集群管理器,它在开源和商业的本地(例如 Cloudera)和云(例如 Elastic Map Reduce)环境中都有提供。在 YARN 集群上运行 Dask 有两种方式:一种是使用 Dask-Yarn,另一种是使用 Dask-Gateway。尽管这两种方法相似,但 Dask-Gateway 可能需要更多的操作,因为它添加了一个集中管理的服务器来管理 Dask 集群,但它具有更精细的安全性和管理控制。
根据集群的不同,您的工作节点可能比其他类型的工作节点更加短暂,并且它们的 IP 地址在重新启动时可能不是静态的。您应确保为自己的集群设置工作节点/调度器服务发现方法。可以简单地使用一个共享文件让它们读取,或者使用更可靠的代理。如果没有提供额外的参数,Dask 工作节点将使用DASK_SCHEDULER_ADDRESS
环境变量进行连接。
示例 12-4 在一个自定义的 conda 环境和日志框架中扩展了示例 9-1。
示例 12-4. 使用自定义 conda 环境在 YARN 上部署 Dask
from dask_yarn import YarnCluster
from dask.distributed import Client
import logging
import os
import sys
import time
logger = logging.getLogger(__name__)
WORKER_ENV = {
"HADOOP_CONF_DIR": "/data/app/spark-yarn/hadoop-conf",
"JAVA_HOME": "/usr/lib/jvm/java"}
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s %(levelname)s %(name)s: %(message)s")
logger.info("Initializing YarnCluster")
cluster_start_time = time.time()
# say your desired conda environment for workers is located at
# /home/mkimmins/anaconda/bin/python
# similar syntax for venv and python executable
cluster = YarnCluster(
environment='conda:///home/mkimmins/anaconda/bin/python',
worker_vcores=2,
worker_memory="4GiB")
logger.info(
"Initializing YarnCluster: done in %.4f",
time.time() -
cluster_start_time)
logger.info("Initializing Client")
client = Client(cluster)
logger.info(
"Initializing Client: done in %.4f",
time.time() -
client_start_time)
# Important and common misconfig is mismatched versions on nodes
versions = dask_client.get_versions(check=True)
或者,您可以使用 Dask-Yarn 公开的 CLI 界面运行集群。您首先会在您选择的 Shell 脚本中部署 YARN;然后,Shell 脚本会调用您要运行的 Python 文件。在 Python 文件中,您引用部署的 YARN 集群,如示例 12-5 所示。这可以是一个更简单的方式来链接您的作业并检查和获取日志。请注意,CLI 仅在 Python 版本高于 2.7.6 时受支持。
示例 12-5. 使用 CLI 界面在 YARN 上部署 Dask
get_ipython().system('dask-yarn submit')
'''
--environment home/username/anaconda/bin/python --worker-count 20 \
--worker-vcores 2 --worker-memory 4GiB your_python_script.py
'''
# Since we already deployed and ran YARN cluster,
# we replace YarnCluster(...) with from_current() to reference it
cluster = YarnCluster.from_current()
# This would give you YARN application ID
# application_1516806604516_0019
# status check, kill, view log of application
get_ipython().system('dask-yarn status application_1516806604516_0019')
get_ipython().system('dask-yarn kill application_1516806604516_0019')
get_ipython().system('yarn logs -applicationId application_1516806604516_0019')
在高性能计算中的 Dask
Dask 已经获得了大量的学术和科学用户群体。这在一定程度上归功于使用现有的高性能计算(HPC)集群与 Dask 一起,可以轻松实现可扩展的科学计算,而无需重写所有代码。⁵
您可以将您的 HPC 帐户转换为高性能 Dask 环境,从而可以在本地机器上的 Jupyter 中连接到它。Dask 使用其 Dask-jobqueue 库来支持许多 HPC 集群类型,包括 HTCondor、LSF、Moab、OAR、PBS、SGE、TORQUE、DRMAA 和 Slurm。另一个库 Dask-MPI 支持 MPI 集群。在 示例 9-2 中,我们展示了如何在 Slurm 上使用 Dask 的示例,并在接下来的部分中,我们将进一步扩展该示例。
在远程集群中设置 Dask
在集群上使用 Dask 的第一步是在集群中设置自己的 Python 和 iPython 环境。确切的操作方法会因集群管理员的偏好而异。一般来说,用户通常使用 virtualenv 或 miniconda 在用户级别安装相关库。Miniconda 不仅可以更轻松地使用您自己的库,还可以使用您自己的 Python 版本。完成此操作后,请确保您的 Python 命令指向用户空间中的 Python 二进制文件,方法是运行which python
或安装和导入系统 Python 中不可用的库。
Dask-jobqueue 库将您的 Dask 设置和配置转换为一个作业脚本,该脚本将提交到 HPC 集群。以下示例启动了一个包含 Slurm 工作节点的集群,对其他 HPC API,语义类似。Dask-MPI 使用稍有不同的模式,因此请务必参考其文档获取详细信息。job_directives_skip
是一个可选参数,用于忽略自动生成的作业脚本插入您的特定集群不识别的命令的错误。job_script_prologue
也是一个可选参数,指定在每次工作节点生成时运行的 shell 命令。这是确保设置适当的 Python 环境或特定集群设置脚本的好地方。
小贴士
确保工作节点的内存和核心的 HPC 集群规格在resource_spec
参数中正确匹配,这些参数将传递给您的 HPC 系统本身来请求工作节点。前者用于 Dask 调度器设置其内部;后者用于您在 HPC 内部请求资源。
HPC 系统通常利用高性能网络接口,这是在标准以太网网络之上加快数据移动的关键方法。您可以通过将可选的接口参数传递给 Dask(如在 示例 12-6 中所示),以指示其使用更高带宽的网络。如果不确定哪些接口可用,请在终端上输入ifconfig
,它将显示 Infiniband,通常为 ib0
,作为可用网络接口之一。
最后,核心和内存描述是每个工作节点资源,n_workers
指定您想要最初排队的作业数量。您可以像在 示例 12-6 中那样,在事后扩展和添加更多工作节点,使用 cluster.scale()
命令。
小贴士
一些 HPC 系统在使用 GB 时实际上是指 1024 为基础的单位。Dask-jobqueue 坚持使用 GiB 的正确符号。1 GB 等于 1000³字节,而 1 GiB 等于 1024³字节。学术设置通常使用二进制测量单位,而商业设置通常选择 SI 单位,因此存在差异。
在新环境中运行 Dask 之前,您应该检查由 Dask-jobqueue 自动生成的作业脚本,以查找不受支持的命令。虽然 Dask 的作业队列库尝试与许多 HPC 系统兼容,但可能不具备您机构设置的所有特殊性。如果您熟悉集群的能力,可以通过调用print(cluster.job_script())
来查找不受支持的命令。您还可以尝试先运行一个小版本的作业,使用有限数量的工作节点,看看它们在哪里失败。如果发现脚本存在任何问题,应使用job_directives_skip
参数跳过不受支持的组件,如示例 12-6 所述。
示例 12-6. 手动在 HPC 集群上部署 Dask
from dask_jobqueue import SLURMCluster
from dask.distributed import Client
def create_slurm_clusters(cores, processes, workers, memory="16GB",
queue='regular', account="account", username="user"):
cluster = SLURMCluster(
#ensure walltime request is reasonable within your specific cluster
walltime="04:00:00",
queue=queue,
account=account,
cores=cores,
processes=processes,
memory=memory,
worker_extra_args=["--resources GPU=1"],
job_extra=['--gres=gpu:1'],
job_directives_skip=['--mem', 'another-string'],
job_script_prologue=[
'/your_path/pre_run_script.sh',
'source venv/bin/activate'],
interface='ib0',
log_directory='dask_slurm_logs',
python=f'srun -n 1 -c {processes} python',
local_directory=f'/dev/{username}',
death_timeout=300
)
cluster.start_workers(workers)
return cluster
cluster = create_slurm_clusters(cores=4, processes=1, workers=4)
cluster.scale(10)
client = Client(cluster)
在示例 12-7 中,我们整合了许多我们介绍的概念。在这里,我们使用 Dask delayed 执行了一些异步任务,该任务部署在一个 Slurm 集群上。该示例还结合了我们提到的几种日志记录策略,例如显示底层部署的 HPC 作业脚本,并为用户在笔记本或所选择的 CLI 中提供进度条以跟踪进度。
示例 12-7. 使用 Dask futures 在 Slurm 上通过 jobqueue 部署 Dask
import time
from dask import delayed
from dask.distributed import Client, LocalCluster
# Note we introduce progress bar for future execution in a distributed
# context here
from dask.distributed import progress
from dask_jobqueue import SLURMCluster
import numpy as np
import logging
logger = logging.getLogger(__name__)
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s %(levelname)s %(name)s: %(message)s")
def visit_url(i):
return "Some fancy operation happened. Trust me."
@delayed
def crawl(url, depth=0, maxdepth=1, maxlinks=4):
# some complicated and async job
# refer to Chapter 2 for full implementation of crawl
time.sleep(1)
some_output = visit_url(url)
return some_output
def main_event(client):
njobs = 100
outputs = []
for i in range(njobs):
# assume we have a queue of work to do
url = work_queue.deque()
output = crawl(url)
outputs.append(output)
results = client.persist(outputs)
logger.info(f"Running main loop...")
progress(results)
def cli():
cluster = create_slurm_clusters(cores=10, processes=10, workers=2)
logger.info(f"Submitting SLURM job with jobscript: {cluster.job_script()}")
client = Client(cluster)
main_event(client)
if __name__ == "__main__":
logger.info("Initializing SLURM Cluster")
cli()
提示
始终确保您的 walltime 请求不会违反 HPC 资源管理器的规则。例如,Slurm 有一个后台填充调度程序,应用其自己的逻辑,如果您请求的 walltime 过长,则可能会导致您的计算资源请求在队列中被卡住,无法按时启动。在这种情况下,Dask 客户端可能会因为“Failed to start worker process. Restarting.”等非描述性消息而报错。在撰写本文时,尚没有太多方法可以从用户端的日志代码中突显特定的部署问题。
在更高级的情况下,您可以通过更新在第一次运行时生成并存储在*/.config/dask/jobqueue.yaml*路径下的 Dask-jobqueue YAML 文件来控制集群配置。作业队列配置文件包含了许多不同类型集群的默认配置,这些配置被注释掉了。要开始编辑该文件,取消注释您正在使用的集群类型(例如,Slurm),然后您可以更改值以满足您的特定需求。作业队列配置文件允许您配置通过 Python 构造函数无法访问的附加参数。
如果 Dask 开始内存不足,它将默认开始将数据写入磁盘(称为溢写到磁盘)。这通常很好,因为我们通常有更多的磁盘空间而不是内存,尽管它较慢,但速度并不会慢太多。但是,在 HPC 环境中,Dask 可能写入的默认位置可能是网络存储驱动器,这将像在网络上传输数据一样慢。您应确保 Dask 写入本地存储。您可以向集群管理员询问本地临时目录,或者使用 df -h
查看不同存储设备映射到哪里。如果没有可用的本地存储,或者存储太小,还可以关闭溢写到磁盘功能。在集群上配置禁用和更改溢写到磁盘的位置可以在 ~/.config/dask/distributed.yaml 文件中进行(首次运行时也会创建此文件)。
小贴士
自适应缩放 是在应用程序运行时调整作业大小的好方法,特别是在忙碌的共享机器(如 HPC 系统)上。然而,每个 HPC 系统都是独特的,有时 Dask-jobqueue 处理自适应缩放方式可能会出现问题。我们在使用 jobqueue 在 Slurm 上运行 Dask 自适应缩放时遇到了这样的问题,但通过一些努力,我们能够正确配置它。
Dask 也使用文件进行锁定,在使用共享网络驱动器时可能会出现问题,这在 HPC 集群中很常见。如果有多个工作进程同时运行,它会使用锁定机制,该机制会排除其他进程访问此文件,以协调自身。在 HPC 上的一些问题可能归结为锁定事务不完整,或者由于管理限制而无法在磁盘上写入文件。可以切换工作进程配置以禁用此行为。
小贴士
集群参数,如内存分配和作业数、工作进程、进程和任务每个任务的 CPU 数,对用户输入非常敏感,初学者可能难以理解。例如,如果使用多个进程启动 HPC 集群,则每个进程将占用总分配内存的一部分。10 个进程配备 30 GB 内存意味着每个进程获取 3 GB 内存。如果您的工作流在峰值时占用了超过 95% 的进程内存(例如我们的示例中的 2.85 GB),您的进程将因内存溢出风险而被暂停甚至提前终止,可能导致任务失败。有关内存管理的更多信息,请参阅 “工作进程内存管理”。
对于 HPC 用户,大多数启动的进程都将有一个有限的墙上时间,该作业允许保持运行。您可以以一种方式交错地创建工作进程,以便始终至少有一个工作进程在运行,从而创建一个无限工作循环。或者,您还可以交错地创建和结束工作进程,以避免所有工作进程同时结束。示例 12-8 展示了如何做到这一点。
示例 12-8. 自适应缩放管理 Dask 工作节点
from dask_jobqueue import SLURMCluster
from dask import delayed
from dask.distributed import Client
#we give walltime of 4 hours to the cluster spawn
#each Dask worker is told they have 5 min less than that for Dask to manage
#we tell workers to stagger their start and close in a random interval of 5 min
# some workers will die, but others will be staggered alive, avoiding loss
# of job
cluster = SLURMCluster(
walltime="04:00:00",
cores=24,
processes=6
memory="8gb",
#args passed directly to worker
worker_extra_args=["--lifetime", "235m", "--lifetime-stagger", "5m"],
#path to the interpreter that you want to run the batch submission script
shebang='#!/usr/bin/env zsh',
#path to desired python runtime if you have a separate one
python='~/miniconda/bin/python'
)
client = Client(cluster)
提示
不同的工作节点启动时间可能不同,并且包含的数据量也可能不同,这会影响故障恢复的成本。
虽然 Dask 有良好的工具来监控其自身的行为,但有时 Dask 与您的 HPC 集群(或其他集群)之间的集成可能会中断。如果您怀疑 jobqueue 没有为特定集群发送正确的工作节点命令,您可以直接检查或在运行时动态检查 /.config/dask/jobqueue.yaml
文件,或者在 Jupyter 笔记本中运行 config.get('jobqueue.yaml')
。
将本地机器连接到 HPC 集群
远程运行 Dask 的一部分是能够连接到服务器以运行您的任务。如果您希望将客户端连接到远程集群,远程运行 Jupyter,或者只是在集群上访问 UI,则需要能够连接到远程机器上的一些端口。
警告
另一种选择是让 Dask 绑定到公共 IP 地址,但是如果没有仔细配置防火墙规则,这意味着任何人都可以访问您的 Dask 集群,这可能不是您的意图。
在 HPC 环境中,通常已经使用 SSH 进行连接,因此使用 SSH 端口转发通常是最简便的连接方式。SSH 端口转发允许您将另一台计算机上的端口映射到本地计算机上的一个端口。⁶ 默认的 Dask 监控端口是 8787,但如果该端口已被占用(或者您配置了不同的端口),Dask 可能会绑定到其他端口。Dask 服务器在启动时会打印绑定的端口信息。要将远程机器上的 8787 端口转发到本地相同的端口,您可以运行 ssh -L localhost:8787:my-awesome-hpc-node.hpc.fake:8787
。您可以使用相同的技术(但使用不同的端口号)连接远程 JupyterLab,或者将 Dask 客户端连接到远程调度程序。
提示
如果您希望远程保持运行某个进程(如 JupyterLab),screen
命令是让进程持续超出单个会话的好方法。
随着笔记本的广泛流行,一些 HPC 集群提供特殊工具,使启动 Jupyter 笔记本更加简便。我们建议查阅您集群管理员的文档,了解如何正确启动 Jupyter 笔记本,否则可能会导致安全问题。
Dask JupyterLab 扩展和魔法命令
您可以像运行其他库一样在 Jupyter 中运行 Dask,但是使用 Dask 的 JupyterLab 扩展可以更轻松地了解您的 Dask 作业在运行时的状态。
安装 JupyterLab 扩展
Dask 的 lab 扩展需要安装 nodejs
,可以通过 conda install -c conda-forge nodejs
安装。如果您没有使用 conda,也可以在苹果上使用 brew install node
或者在 Ubuntu 上使用 sudo apt install nodejs
安装。
Dask 的 lab 扩展包名为 dask-labextension
。
安装完实验室扩展后,它将显示带有 Dask 标志的左侧,如图 12-1 所示。
图 12-1. 在 JupyterLab 上成功部署的 Dask 实例(数字,彩色版本)
启动集群
从那里,您可以启动您的集群。默认情况下,该扩展程序启动一个本地集群,但您可以通过编辑~/.config/dask来配置它以使用不同的部署选项,包括 Kubernetes。
用户界面
如果您正在使用 Dask 的 JupyterLab 扩展(参见图 12-2),它提供了一个到集群 UI 的链接,以及将单个组件拖放到 Jupyter 界面中的功能。
图 12-2. 在 JupyterHub 内使用 JupyterLab 扩展显示的 Dask Web UI(数字,彩色版本)
JupyterLab 扩展程序链接到 Dask Web UI,您还可以通过集群的repr
获取链接。如果集群链接不起作用/无法访问,您可以尝试安装jupyter-server-proxy
扩展程序,以便将笔记本主机用作跳转主机。
观察进度
Dask 作业通常需要很长时间才能运行;否则我们不会努力并行化它们。您可以使用 Dask 的dask.distributed
中的progress
函数来跟踪您笔记本中的未来进度(参见图 12-3)。
图 12-3. 在 JupyterHub 中实时监控 Dask 进度(数字,彩色版本)
理解 Dask 性能
调整您的 Dask 程序涉及理解多个组件的交集。您需要了解您的代码行为以及其与给定数据和机器的交互方式。您可以使用 Dask 指标来深入了解其中的许多内容,但特别是如果不是您创建的代码,查看程序本身也很重要。
分布式计算中的指标
分布式计算需要不断做出决策,并权衡分发工作负载的优化成本和收益。大部分低级别的决策都委托给 Dask 的内部。用户仍应监控运行时特性,并根据需要修改代码和配置。
Dask 会自动跟踪相关的计算和运行时指标。您可以利用这一点来帮助决定如何存储数据,以及在优化代码时应该关注哪些方面。
当然,计算成本不仅仅是计算时间。用户还应考虑通过网络传输数据的时间,工作节点内存占用情况,GPU/CPU 利用率以及磁盘 I/O 成本。这些因素帮助理解数据移动和计算流的更高层次洞见,比如工作节点中有多少内存用于存储尚未传递给下一个计算的先前计算,或者哪些特定函数占用了大部分时间。监控这些可以帮助优化集群和代码,同时还可以帮助识别可能出现的计算模式或逻辑瓶颈,从而进行调整。
Dask 的仪表板提供了大量统计数据和图表来回答这些问题。该仪表板是一个与您的 Dask 集群在运行时绑定的网页。您可以通过本地机器或运行它的远程机器访问它,方法我们在本章前面已经讨论过。在这里,我们将覆盖一些从性能指标中获取洞见并据此调整 Dask 以获得更好结果的方法。
Dask 仪表板
Dask 的仪表板包含许多不同页面,每个页面可以帮助理解程序的不同部分。
任务流
任务流仪表板提供了每个工作节点及其行为的高级视图。精确调用的方法以颜色代码显示,并可以通过缩放来检查它们。每行代表一个工作节点。自定义颜色的条形图是用户生成的任务,有四种预设颜色表示常见的工作节点任务:工作节点之间的数据传输、磁盘读写、序列化和反序列化时间以及失败的任务。图 12-4 展示了分布在 10 个工作节点上的计算工作负载,平衡良好,没有一个工作节点完成较晚,计算时间均匀分布,并且最小化了网络 IO 开销。
图 12-4. 带有良好平衡工作节点的任务流(数字版,彩色)
另一方面,图 12-5 展示了计算不均匀的情况。你可以看到计算之间有很多空白,这意味着工作人员被阻塞,并且在此期间实际上没有计算。此外,您可以看到一些工作人员开始较早,而其他人结束较晚,暗示代码分发中存在问题。这可能是由于代码本身的依赖性或子优化调整不当所致。改变 DataFrame 或数组块的大小可能会减少这些碎片化。您可以看到,每个工作人员启动工作时,他们处理的工作量大致相同,这意味着工作本身仍然相当平衡,并且分配工作负载带来了良好的回报。这是一个相当虚构的例子,因此此任务仅花了几秒钟,但相同的想法也适用于更长和更笨重的工作负载。
图 12-5. 任务流中有太多小数据块(数字,彩色版)
内存
您可以监视内存使用情况,有时称为内存压力,⁷ 每个工作人员在“存储字节”部分的使用情况(参见图 12-6)。这些默认情况下颜色编码,表示在限制内存压力、接近限制和溢出到磁盘。即使内存使用在限制内,当其超过 60%至 70%时,可能会遇到性能减慢。由于内存使用正在上升,Python 和 Dask 的内部将运行更昂贵的垃圾收集和内存优化任务,以防止其上升。
图 12-6. 监视 UI 中每个工作人员的内存使用情况(数字,彩色版)
任务进度
你可以通过进度条看到任务完成的汇总视图,参见图 12-7。执行顺序是从上到下,虽然这并不总是完全顺序的。条的颜色对调整特别信息丰富。在图 12-7 中,sum()
和 random_sample()
的实心灰色表示任务准备运行,依赖数据已准备好但尚未分配给工作人员。加粗的非灰色条表示任务已完成,结果数据等待下一个任务序列处理。较淡的非灰色块表示任务已完成,结果数据已移交并从内存中清除。您的目标是保持实心色块的可管理大小,以确保充分利用分配的大部分内存。
图 12-7. 按任务监视的进度,所有工作人员汇总(数字,彩色版)
任务图
类似的信息也可以在任务图上找到(参见 图 12-8),从单个任务的视角来看。您可能熟悉这些类似 MapReduce 的有向无环图。计算顺序从左到右显示,您的任务来源于许多并行工作负载,分布在工作人员之间,并以此种方式结束,最终得到由 10 个工作人员分布的结果。该图还准确地描述了任务依赖关系的低级视图。颜色编码还突出显示了计算生命周期中当前每个工作和数据所处的位置。通过查看这些信息,您可以了解哪些任务是瓶颈,因此可能是优化代码的良好起点。
图 12-8. 显示每个任务的颜色编码状态及其前后任务的任务图(数字,彩色版本)
工作人员标签页允许您实时查看 CPU、内存和磁盘 IO 等情况(参见 图 12-9)。如果您怀疑您的工作人员内存不足或磁盘空间不足,监视此标签页可能会很有用。解决这些问题的一些方法可以包括为工作人员分配更多内存或选择不同的数据分块大小或方法。
图 12-10 显示了工作事件监视。Dask 的分布式调度器在称为事件循环的循环上运行,该循环管理要安排的所有任务以及管理执行、通信和计算状态的工作人员。event_loop_interval
指标衡量了每个工作人员的此循环迭代之间的平均时间。较短的时间意味着调度器在为该工作人员执行其管理任务时花费的时间较少。如果此时间增加,可能意味着诸如网络配置不佳、资源争用或高通信开销等问题。如果保持较高,您可能需要查看是否为计算分配了足够的资源,并且可以为每个工作人员分配更大的资源或重新对数据进行分块。
图 12-9. 具有 10 个工作人员的 Dask 集群的工作人员监视(数字,彩色版本)
图 12-10. Dask 集群的工作事件监视(数字,彩色版本)
系统标签允许您跟踪 CPU、内存、网络带宽和文件描述符的使用情况。CPU 和内存易于理解。如果作业需要大量数据传输,那么 HPC 用户会特别关注网络带宽。这里的文件描述符跟踪系统同时打开的输入和输出资源数量。这包括实际打开的读/写文件,以及在机器之间通信的网络套接字。系统同时可以打开的描述符数量有限,因此一个非常复杂的作业或者开启了许多连接但未关闭的漏洞工作负载可能会造成问题。类似于内存泄漏,这会随着时间的推移导致性能问题。
Profile 标签允许您查看执行代码所花费的时间,可以精确到每次函数调用的细节,以聚合级别显示。这有助于识别造成瓶颈的任务。Figure 12-11 显示了一个任务持续时间直方图,展示了每个任务及其所有调用的子例程的细粒度视图,以及它们的运行时间。这有助于快速识别比其他任务持续时间更长的任务。
图 12-11. Dask 作业的任务持续时间直方图(数字,彩色版本)
提示
您可以通过 Dask 客户端配置中的 distributed.client.scheduler-info-interval
参数更改日志记录间隔。
保存和共享 Dask 指标/性能日志
您可以通过仪表板实时监控 Dask,但一旦关闭集群,仪表板将消失。您可以保存 HTML 页面,导出指标为 DataFrame,并编写用于指标的自定义代码(参见 Example 12-9)。
示例 12-9. 生成并保存 Dask 仪表板至文件
from dask.distributed import performance_report
with performance_report(filename="computation_report.html"):
gnarl = da.random.beta(
1, 2, size=(
10000, 10000, 10), chunks=(
1000, 1000, 5))
x = da.random.random((10000, 10000, 10), chunks=(1000, 1000, 5))
y = (da.arccos(x) * gnarl).sum(axis=(1, 2))
y.compute()
您可以为任何计算块手动生成性能报告,而无需保存整个运行时报告,只需使用 Example 12-9 中的代码执行 performance_report("filename")
。请注意,在幕后,这需要安装 Bokeh。
对于更加重型的使用,您可以结合流行的 Python 指标和警报工具 Prometheus 使用 Dask。这需要您已部署 Prometheus。然后通过 Prometheus,您可以连接其他工具,例如用于可视化的 Grafana 或用于警报的 PagerDuty。
Dask 的分布式调度器提供了作为任务流对象的度量信息,而无需使用 UI 本身。您可以直接从 Dask 的任务流 UI 标签中访问信息,以及您希望对其进行性能分析的代码行级别。示例 12-10 展示了如何使用任务流,并将一些统计信息提取到一个小的 pandas DataFrame 中,以供进一步分析和分享。
示例 12-10. 使用任务流生成和计算 Dask 运行时统计
from dask.distributed import get_task_stream
with get_task_stream() as ts:
gnarl = da.random.beta(1, 2, size=(100, 100, 10), chunks=(100, 100, 5))
x = da.random.random((100, 100, 10), chunks=(100, 100, 5))
y = (da.arccos(x) * gnarl).sum(axis=(1, 2))
y.compute()
history = ts.data
#display the task stream data as dataframe
history_frame = pd.DataFrame(
history,
columns=[
'worker',
'status',
'nbytes',
'thread',
'type',
'typename',
'metadata',
'startstops',
'key'])
#plot task stream
ts.figure
高级诊断
您可以使用dask.distributed.diagnostics
类插入自定义度量。其中一个函数是MemorySampler
上下文管理器。当您在ms.sample()
中运行您的 Dask 代码时,它会记录集群上的详细内存使用情况。示例 12-11 虽然是人为的,但展示了如何在两种不同的集群配置上运行相同的计算,然后绘制以比较两个不同的环境配置。
示例 12-11. 为您的代码插入内存采样器
from distributed.diagnostics import MemorySampler
from dask_kubernetes import KubeCluster
from distributed import Client
cluster = KubeCluster()
client = Client(cluster)
ms = MemorySampler()
#some gnarly compute
gnarl = da.random.beta(1, 2, size=(100, 100, 10), chunks=(100, 100, 5))
x = da.random.random((100, 100, 10), chunks=(100, 100, 5))
y = (da.arccos(x) * gnarl).sum(axis=(1, 2))
with ms.sample("memory without adaptive clusters"):
y.compute()
#enable adaptive scaling
cluster.adapt(minimum=0, maximum=100)
with ms.sample("memory with adaptive clusters"):
y.compute()
#plot the differences
ms.plot(align=True, grid=True)
扩展和调试最佳实践
在这里,我们讨论了在分布式集群设置中运行代码时常见的问题和被忽视的考虑因素。
手动扩展
如果您的集群管理器支持,您可以通过调用scale
并设置所需的工作节点数来进行工作节点的动态扩展和缩减。您还可以告知 Dask 调度器等待直到请求的工作节点数分配完成,然后再使用client.wait_for_workers(n_workers)
命令进行计算。这在训练某些机器学习模型时非常有用。
自适应/自动扩展
我们在前几章节简要介绍了自适应扩展。您可以通过在 Dask 客户端上调用adapt()
来启用集群的自动/自适应扩展。调度器会分析计算并调用scale
命令来增加或减少工作节点。Dask 集群类型——KubeCluster、PBSCluster、LocalClusters 等——是处理实际请求以及工作节点的动态扩展和缩减的集群类。如果在自适应扩展中遇到问题,请确保您的 Dask 正确地向集群管理器请求资源。当然,要使 Dask 中的自动扩展生效,您必须能够在运行作业的集群内部自行扩展资源分配,无论是 HPC、托管云等。我们在示例 12-11 中已经介绍了自适应扩展;请参考该示例获取代码片段。
持久化和删除成本高昂的数据
一些中间结果可以在代码执行的后续阶段使用,但不能立即使用。在这些情况下,Dask 可能会删除数据,而不会意识到在稍后会再次需要它,从而需要进行另一轮昂贵的计算。如果识别出这种模式,可以使用.persist()
命令。使用此命令时,还应使用 Python 的内置del
命令,以确保数据在不再需要时被删除。
Dask Nanny
Dask Nanny 是一个管理工作进程的进程。它的工作是防止工作进程超出其资源限制,导致机器状态无法恢复。它不断监视工作进程的 CPU 和内存使用情况,并触发内存清理和压缩。如果工作进程达到糟糕的状态,它会自动重新启动工作进程,并尝试恢复先前的状态。
如果某个工作进程因某种原因丢失,其中包含计算密集和大数据块,可能会出现问题。Nanny 将重新启动工作进程,并尝试重新执行导致问题的工作。在此期间,其他工作进程也将保留它们正在处理的数据,导致内存使用量激增。解决此类问题的策略各不相同,可以禁用 Nanny,修改块大小、工作进程大小等。如果此类情况经常发生,应考虑持久化或将数据写入磁盘。⁸
如果看到诸如“工作进程超过 95% 内存预算。正在重启”之类的错误消息,则很可能是 Nanny 引起的。它是负责启动、监视、终止和重新启动工作进程的类。这种内存分数以及溢出位置可以在 distributed.yaml 配置文件中设置。如果 HPC 用户的系统本身具有自己的内存管理策略,则可以关闭 Nanny 的内存监控。如果系统还重新启动被终止的作业,则可以使用 --no-nanny
选项关闭 Nanny。
工作进程内存管理
默认情况下,当工作进程的内存使用达到大约 60% 时,它开始将一些数据发送到磁盘。超过 80% 时,停止分配新数据。达到 95% 时,工作进程会预防性地终止,以避免内存耗尽。这意味着在工作进程的内存使用超过 60% 后,性能会下降,通常最好保持内存压力较低。
高级用户可以使用 Active Memory Manager,这是一个守护进程,从整体视角优化集群工作进程的内存使用。您可以为此管理器设定特定的优化目标,例如减少集群内相同数据的复制,或者在工作进程退休时进行内存转移,或其他自定义策略。在某些情况下,Active Memory Manager 已被证明能够减少相同任务的内存使用高达 20%。⁹
集群规模
自动/自适应缩放解决了“有多少”工作进程的问题,但没有解决每个工作进程“有多大”的问题。尽管如此,以下是一些经验法则:
-
在调试时使用较小的工作进程大小,除非你预期 bug 是由于大量工作进程导致的。
-
根据输入数据大小和使用的工作进程数量调整工作进程内存分配。
-
数据中的块数应大致匹配工作人员的数量。工作人员少于块数将导致一些块在第一轮计算结束之前未被处理,从而导致中间数据的内存占用量较大。相反,工作人员多于块数将导致空闲的工作人员。
-
如果你可以选择高工作人数和较小的单个工作内存(与较少工作人数和更大的单个工作内存相比),分析你的数据块大小。这些块必须适合一个工作人员进行一些计算,并设置工作人员所需的最小内存。
调整你的机器大小可能成为一个永无止境的练习,所以了解对你的目的来说什么是“足够好”的很重要。
块划分再探讨
我们之前简要讨论了块和块大小,现在我们将此扩展到集群规模。块大小和工作人员大小对于 Dask 的功能至关重要,因为它使用任务图执行模型中的块级视图来进行计算和数据。这是决定分布式计算如何工作的重要参数。在使用 Dask 和其他分布式系统时,我们发现这是在调整这些大型机器的旋钮和控制器时要记住的重要理念之一。
对于正在进行的任何给定的工作人员硬件配置和计算,都会有一个适合块大小的最佳点,用户的任务是设置这个大小。找到确切的数字可能没有用,但大致找到可能会给你带来最佳结果的配置类型可以产生巨大的差异。
块划分的关键思想是在计算和存储之间实现负载平衡,但会增加通信的开销。在一个极端,你有单机数据工程,数据在一个 pandas DataFrame 中,或者一个没有分区的单个 Dask DataFrame 中。通信成本不高,因为所有通信发生在 RAM 和 GPU 或 CPU 之间,数据通过单台计算机的主板移动。随着数据大小的增长,这种单体块将无法工作,你会遇到内存不足的错误,失去所有之前在内存中的计算。因此,你会使用像 Dask 这样的分布式系统。
在另一个极端,一个非常分散的数据集,使用多台机器通过以太网连接,将在通信开销增加时更慢地共同工作,甚至可能超出调度程序处理通信、收集和协调的能力。在现代分布式数据工程中,保持两个极端之间的良好平衡,并了解哪个问题需要哪些工具,是一项重要工作。
避免重新划分块
在将多个数据流管道化到作业中时,你可能会有两个数据集,其数据维度匹配,但具有不同的块大小。在运行时,Dask 将不得不重新对一个数据集进行重新分块,以匹配另一个数据集的块大小。如果发现了这种情况,可以考虑在作业进入之前执行单独的重新分块作业。
计划任务
有许多不同的系统可以让你的作业按计划运行。这些计划可以是周期性的和基于时间的,也可以是由上游事件触发的(比如数据变为可用)。流行的调度作业工具包括 Apache Airflow、Flyte、Argo、GitHub Actions 和 Kubeflow。¹⁰ Airflow 和 Flyte 内置支持 Dask,可以简化运行计划任务,因此我们认为它们都是优秀的 Dask 计划任务选项。内置操作符使得跟踪失败更加容易,这很重要,因为对陈旧数据采取行动和对错误数据采取行动一样糟糕。
我们也经常看到人们使用 Unix crontab 和 schtasks,但我们建议不要这样做,因为它们只在单台机器上运行,并且需要大量的额外工作。
提示
对于 Kubernetes 上的计划任务,你还可以让调度器创建一个 DaskJob 资源,这将在集群内运行你的 Dask 程序。
在 附录 A 中,你将了解有关测试和验证的详细信息,这对于计划和自动化作业尤为重要,因为没有时间进行手动检查。
部署监控
像许多其他分布式库一样,Dask 提供了日志记录功能,你可以配置 Dask 日志记录发送到存储系统。具体方法会因部署环境以及是否使用 Jupyter 而有所不同。
你可以通过 Dask 客户端的 get_worker_logs()
和 get_scheduler_logs()
方法通用地获取工作器和调度器的日志。你可以指定特定的主题来记录或读取相关主题的日志。更多信息请参考 示例 9-6。
你不仅限于记录字符串,还可以记录结构化事件。这在性能分析或者日志消息可能被可视化而不是人工逐个查看的情况下特别有用。在 示例 12-12 中,我们通过分布式softmax
函数实现了这一点,并记录了事件,并在客户端检索它们。
示例 12-12. 工作器上的结构化日志记录
from dask.distributed import Client, LocalCluster
client = Client(cluster) # Connect to distributed cluster and override default
d = {'x': [3.0, 1.0, 0.2], 'y': [2.0, 0.5, 0.1], 'z': [1.0, 0.2, 0.4]}
scores_df = dd.from_pandas(pd.DataFrame(data=d), npartitions=1)
def compute_softmax(partition, axis=0):
""" computes the softmax of the logits
:param logits: the vector to compute the softmax over
:param axis: the axis we are summing over
:return: the softmax of the vector
"""
if partition.empty:
return
import timeit
x = partition[['x', 'y', 'z']].values.tolist()
start = timeit.default_timer()
axis = 0
e = np.exp(x - np.max(x))
ret = e / np.sum(e, axis=axis)
stop = timeit.default_timer()
partition.log_event("softmax", {"start": start, "x": x, "stop": stop})
dask.distributed.get_worker().log_event(
"softmax", {"start": start, "input": x, "stop": stop})
return ret
scores_df.apply(compute_softmax, axis=1, meta=object).compute()
client.get_events("softmax")
结论
在本章中,你已经学到了 Dask 分布式的各种部署选项,从大众化云到 HPC 基础设施。你还学到了简化远程部署获取信息的 Jupyter 魔术。根据我们的经验,Dask 在 Kubernetes 上和 Dask 在 Ray 上的 Kubernetes 提供了我们需要的灵活性。你自己关于如何部署 Dask 的决定可能会有所不同,特别是如果你在一个拥有现有集群部署的较大机构中工作。大部分部署选项都在“部署 Dask 集群”指南中有详细介绍,但 Dask 在 Ray 上的情况则在Ray 文档中有介绍。
你还学到了运行时考虑因素以及运行分布式工作时要跟踪的度量标准,以及 Dask 仪表板中的各种工具,用于生成更高级的用户定义的度量标准。通过使用这些度量标准,你学到了调整 Dask 分布式集群、故障排除的概念基础,以及这与 Dask 和分布式计算的基本设计原则的关系。
¹ 我们目前并不为云提供商工作,所以如果你的工作负载适合在笔记本电脑上运行,那就更好了。只是记得使用源代码控制。但是,如果可能的话,将其放在服务器上可能是一个有用的练习,以捕获依赖关系并确保您的生产环境可以承受丢失笔记本电脑的情况。
² PEP20 对于显而易见的做事方式的看法仍然更多地是一种建议,而不是普遍遵守的规范。
³ 请注意,这会安装 Helm 3.X。与 Python 3 一样,Helm 3 与 Helm 2 相比有大量的破坏性变化,所以当您阅读文档(或安装软件包)时,请确保它引用的是当前的主要版本。
⁴ 混合的工作类型;参见Dask 文档中的“Worker Resources”以及博客文章“如何使用 Dask Helm 图运行不同的工作类型”。
⁵ 在某些方面,HPC 和集群管理器是同一个东西的不同名称,其中集群管理器来自于工业,而 HPC 来自于研究。HPC 集群倾向于拥有并使用不太常见于工业的共享网络存储。
⁶ 你还可以运行 SSH socks 代理,这样就可以轻松访问 HPC 集群内的其他服务器,但这也需要更改您的浏览器配置(并且对于 Dask 客户端不起作用)。
⁷ 你可以把内存想象成一个我们填充的气球,随着压力的增加,出现问题的可能性也越大。我们承认这个比喻有点牵强。
⁸ 有一个旋钮可以控制前置任务完成的速度。有时候运行所有简单的任务太快可能会导致大量中间数据堆积,后续步骤处理时可能会出现不良的内存饱和现象。查看与 distributed.scheduler.worker-saturation
相关的文档,以获取更多信息。
⁹ 您可以在 Dask 的文档中找到更多信息。
¹⁰ Holden 是 Kubeflow for Machine Learning(O’Reilly)的合著者,所以她在这里有偏见。