Python-Dask-扩展指南-早期发布--全- - 绝不原创的飞龙 - 博客园 (2024)

Table of Contents
大数据 数据科学 并行到分布式 Python Dask 社区库 加速 Python SQL 引擎 工作流调度 任务 Hello World 睡眠任务 嵌套任务 分布式集合 Dask 数组 Dask 包和词频统计 Dask DataFrame(Pandas/人们希望大数据是什么) 本地后端 分布式(Dask 客户端和调度器) 自动扩展 Dask 客户端的重要限制 分布式集群中的库和依赖项 Dask 数组 Dask Bags Dask DataFrames Shuffles 载入期间的分区 惰性评估 任务依赖 visualize 中间任务结果 任务大小 当任务图变得太大 结合计算 持久化、缓存和记忆化 格式 文件系统 滚动窗口和 map_overlap 聚合 完全洗牌和分区 分区 多 DataFrame 内部 缺失功能 决定使用 Dask 使用 Dask 进行探索性数据分析 加载数据 绘制数据 检查数据 常见用例 不适用 Dask 数组的情况 加载/保存 有何缺失 特殊的 Dask 函数 常见用例 加载和保存 Dask Bags 使用 Dask Bag 加载混乱数据 限制 您的第一个 actor(这是一个银行账户) 缩放 Dask Actors 限制 项目优先级 社区 Dask 特定的最佳实践 最新的依赖项 文档 对贡献的开放态度 可扩展性 发布历史 提交频率(和数量) 库的使用情况 代码和最佳实践 集群类型 开发:考虑因素 DataFrame 性能 将 SQL 迁移到 Dask 部署监控 装饰器(包括 Numba) GPU cuDF BlazingSQL cuStreamz 特征工程 模型选择和训练 当没有 Dask-ML 等效时。 使用 Dask 的 joblib。 使用 Dask 的 XGBoost。 手动分发数据和模型 使用 Dask 进行大规模推理 在远程集群中设置 Dask 将本地机器连接到 HPC 集群 安装 JupyterLab 扩展 启动集群 用户界面 观察进度 分布式计算中的指标 Dask 仪表板 任务流 内存 任务进度 任务图 保存和共享 Dask 指标/性能日志 高级诊断 手动扩展 自适应/自动扩展 持久化和删除成本高昂的数据 Dask Nanny 工作进程内存管理 集群规模 块划分再探讨 避免重新划分块 手动测试 单元测试 集成测试 测试驱动开发 属性测试 使用笔记本 外部笔记本测试 笔记本内测试:内联断言 任务并行 数据并行 洗牌和窄与宽转换 限制 负载均衡 仅限单机 Pandas H2O 的 DataTable Polars 分布式 ASF Spark DataFrame SparklingPandas Spark Koalas / Spark pandas DataFrames Cylon Ibis Modin Vanilla Dask DataFrame cuDF

原文:annas-archive.org/md5/51ecaf36908acb7901fbeb7d885469d8

译者:飞龙

协议:CC BY-NC-SA 4.0

我们为熟悉 Python 和 pandas 的数据科学家和数据工程师编写了这本书,他们希望处理比当前工具允许的更大规模的问题。目前的 PySpark 用户会发现,这些材料有些与他们对 PySpark 的现有知识重叠,但我们希望它们仍然有所帮助,并不仅仅是为了远离 Java 虚拟机(JVM)。

如果您对 Python 不太熟悉,一些优秀的 O’Reilly 书籍包括学习 PythonPython 数据分析。如果您和您的团队更频繁地使用 JVM 语言(如 Java 或 Scala),虽然我们有些偏见,但我们鼓励您同时查看 Apache Spark 以及学习 Spark(O’Reilly)和高性能 Spark(O’Reilly)。

本书主要集中在数据科学及相关任务上,因为在我们看来,这是 Dask 最擅长的领域。如果您有一个更一般的问题,Dask 似乎并不是最合适的解决方案,我们(再次有点偏见地)建议您查看使用 Ray 扩展 Python(O’Reilly),这本书的数据科学内容较少。

正如俗语所说,能力越大责任越大。像 Dask 这样的工具使您能够处理更多数据并构建更复杂的模型。重要的是不要因为简单而收集数据,并停下来问问自己,将新字段包含在模型中可能会带来一些意想不到的现实影响。您不必费力去寻找那些好心的工程师和数据科学家不小心建立了具有破坏性影响的模型或工具的故事,比如增加对少数族裔的审计、基于性别的歧视或像词嵌入(将单词的含义表示为向量的一种方法)中的偏见等更微妙的事情。请在使用您新获得的这些潜力时牢记这些潜在后果,因为永远不要因错误原因出现在教科书中。

本书中使用了以下排版约定:

斜体

表示新术语、URL、电子邮件地址、文件名和文件扩展名。

固定宽度

用于程序清单,以及段落内引用程序元素,如变量或函数名、数据库、数据类型、环境变量、语句和关键字。

提示

此元素表示提示或建议。

注释

此元素表示一般注释。

警告

此元素表示警告或注意事项。

印刷版读者可以在 https://oreil.ly/SPWD-figures 找到一些图表的更大、彩色版本。每个图表的链接也出现在它们的标题中。

一旦在印刷版发布,并且不包括 O’Reilly 独特的设计元素(即封面艺术、设计格式、“外观和感觉”)或 O’Reilly 的商标、服务标记和商业名称,本书在 Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International Public License 下可用。我们希望感谢 O’Reilly 允许我们在 Creative Commons 许可下提供本书,并希望您选择通过购买几本书(无论哪个假期季节即将来临)来支持本书(和我们)。

Scaling Python Machine Learning GitHub 仓库 包含本书大部分示例。它们主要位于 dask 目录下,更奥义的部分(如跨平台 CUDA 容器)位于单独的顶级目录中。

如果您有技术问题或使用代码示例时遇到问题,请发送电子邮件至 support@oreilly.com

这本书旨在帮助你完成工作任务。一般来说,如果本书提供了示例代码,你可以在你的程序和文档中使用它。除非你在复制大部分代码,否则无需联系我们获得许可。例如,编写一个使用本书多个代码片段的程序不需要许可。出售或分发 O'Reilly 书籍的示例需要许可。引用本书并引用示例代码回答问题不需要许可。将本书大量示例代码整合到产品文档中需要许可。

我们欢迎,但通常不要求署名。署名通常包括标题、作者、出版商和 ISBN。例如:“Scaling Python with Dask by Holden Karau and Mika Kimmins (O’Reilly). Copyright 2023 Holden Karau and Mika Kimmins, 978-1-098-11987-4.”

如果您认为您对代码示例的使用超出了合理使用或上述许可,请随时通过 permissions@oreilly.com 联系我们。

注意

40 多年来,O’Reilly Media 提供技术和商业培训、知识和见解,帮助公司取得成功。

我们独特的专家和创新者网络通过书籍、文章和我们的在线学习平台分享他们的知识和专业知识。O’Reilly 的在线学习平台为您提供按需访问的实时培训课程、深度学习路径、交互式编码环境以及来自 O’Reilly 和其他 200 多家出版商的广泛文本和视频集合。有关更多信息,请访问 https://oreilly.com

请将关于本书的评论和问题发送至出版社:

  • O’Reilly Media, Inc.

  • 1005 Gravenstein Highway North

  • Sebastopol, CA 95472

  • 800-889-8969(美国或加拿大)

  • 707-829-7019(国际或本地)

  • 707-829-0104(传真)

  • support@oreilly.com

  • https://www.oreilly.com/about/contact.html

我们为这本书建立了一个网页,列出了勘误、示例和任何额外信息。您可以访问https://oreil.ly/scaling-python-dask

欲了解有关我们的书籍和课程的新闻和信息,请访问https://oreilly.com

在 LinkedIn 上找到我们:https://linkedin.com/company/oreilly-media

在 Twitter 上关注我们:https://twitter.com/oreillymedia

在 YouTube 上观看我们:https://youtube.com/oreillymedia

这是两位生活在美国的跨性别移民写的一本书,在这个时候,墙似乎正在向我们逼近。我们选择将这本书献给那些为更公正世界而战的人,无论方式多么微小——谢谢你们。对于我们失去或未能见面的所有人,我们怀念你们。对于我们尚未见面的人,我们期待与你们相遇。

如果没有构建这本书的社区支持,它将无法存在。从 Dask 社区到 PyData 社区,谢谢你们。感谢所有早期的读者和评论者对你们的贡献和指导。这些评论者包括 Ruben Berenguel、Adam Breindel、Tom Drabas、Joseph Gnanaprakasam、John Iannone、Kevin Kho、Jess Males 等。特别感谢 Ann Spencer 在最终成为这本书和Scaling Python with Ray的提案的早期审查中提供的帮助。任何剩余的错误完全是我们自己的责任,有时候我们违背了评论者的建议。¹

Holden 还要感谢她的妻子和伙伴们忍受她长时间的写作时间(有时候在浴缸里)。特别感谢 Timbit 保护房子并给 Holden 一个起床的理由(尽管对她来说有时候会太早)。

Python-Dask-扩展指南-早期发布--全- - 绝不原创的飞龙 - 博客园 (1)

Mika 还要特别感谢 Holden 对她的指导和帮助,并感谢哈佛数据科学系的同事们为她提供无限量的免费咖啡。

¹ 有时我们固执到了极点。

Dask 是一个用于 Python 的并行计算框架,从单机多核扩展到拥有数千台机器的数据中心。它既有低级任务 API,也有更高级的面向数据的 API。低级任务 API 支持 Dask 与多种 Python 库的集成。公共 API 的存在使得围绕 Dask 发展了各种工具的生态系统。

Continuum Analytics,现在被称为 Anaconda Inc,启动了开源、DARPA 资助的 Blaze 项目,该项目演变为 Dask。Continuum 参与开发了 Python 数据分析领域许多重要库甚至会议。Dask 仍然是一个开源项目,现在大部分开发得到 Coiled 的支持。

Dask 在分布式计算生态系统中独具一格,因为它整合了流行的数据科学、并行和科学计算库。Dask 整合不同库的能力允许开发者在规模化时重复使用他们的现有知识。他们还可以最小程度地更改一些代码并频繁重复使用它们。

Dask 简化了用 Python 编写的分析、机器学习和其他代码的扩展,¹ 允许你处理更大更复杂的数据和问题。Dask 的目标是填补现有工具(如 pandas DataFrames 或你的 scikit-learn 机器学习流水线)在处理速度变慢(或无法成功)时的空白。虽然“大数据”这个术语可能比几年前少流行一些,但问题的数据规模并没有减小,计算和模型的复杂性也没有变得更简单。Dask 允许你主要使用你习惯的现有接口(如 pandas 和多进程),同时超越单个核心甚至单台机器的规模。

注意

另一方面,如果你所有的数据都能在笔记本电脑的内存中处理,并且你能在你喝完一杯最喜欢的热饮之前完成分析,那么你可能还不需要使用 Dask。

Dask 提供了对多个传统上独立工具的可扩展性。它通常用于扩展 Python 数据库库,如 pandas 和 NumPy。Dask 扩展了现有的扩展工具,例如多进程,使它们能够超越单机的当前限制,扩展到多核和多机。以下是生态系统演变的简要概述:

先“大数据”查询

Apache Hadoop 和 Apache Hive

后“大数据”查询

Apache Flink 和 Apache Spark

集中于 DataFrame 的分布式工具

Koalas、Ray 和 Dask

从抽象角度来看,Dask 位于机器和集群管理工具之上,使你能够专注于 Python 代码,而不是机器间通信的复杂性:

可扩展的数据和机器学习工具

Hadoop、Hive、Flink、Spark、TensorFlow、Koalas、Ray、Dask 等

计算资源

Apache Hadoop YARN、Kubernetes、Amazon Web Services、Slurm Workload Manager 等。

如果限制因素不是数据量而是我们对数据的处理工作,则我们说问题是计算密集型内存限制问题是指计算不是限制因素;相反,能否将所有数据存储在内存中是限制因素。某些问题既可以是计算密集型又可以是内存密集型,这在大型深度学习问题中经常发生。

多核心(考虑多线程)处理可以帮助解决计算密集型问题(在机器核心数限制内)。通常情况下,多核心处理无法帮助解决内存密集型问题,因为所有中央处理单元(CPU)对内存的访问方式相似。²

加速处理,包括使用专门的指令集或专用硬件如张量处理单元(TPU)或图形处理单元(GPU),通常仅对计算密集型问题有用。有时使用加速处理会引入内存限制问题,因为加速计算的内存可用量可能小于“主”系统内存。

对于这两类问题,多机处理都很重要。因为即使在某些规模上问题“仅”是计算密集型,您也需要考虑多机处理,因为在一台机器上您能(负担得起的话)获得的核心数量有限。更常见的是,内存限制问题非常适合多机扩展,因为 Dask 常常能够将数据分割到不同的机器上。

Dask 既支持多核心,也支持多机器扩展,允许您根据需要扩展 Python 代码。

Dask 的许多功能来自于建立在其之上的工具和库,这些工具和库适应其在数据处理生态系统中的各个部分(如 BlazingSQL)。您的背景和兴趣自然会影响您首次查看 Dask 的方式,因此在接下来的小节中,我们将简要讨论您如何在不同类型的问题上使用 Dask,以及它与一些现有工具的比较。

大数据

Dask 拥有比许多替代方案更好的 Python 库集成和较低的任务开销。Apache Spark(及其 Python 伴侣 PySpark)是最流行的大数据工具之一。现有的大数据工具,如 PySpark,具有更多的数据源和优化器(如谓词下推),但每个任务的开销更高。Dask 的较低开销主要归因于 Python 大数据生态系统的其他部分主要构建在 JVM 之上。这些工具具有高级功能,如查询优化器,但以在 JVM 和 Python 之间复制数据为代价。

与许多其他传统的大数据工具不同,如 Spark 和 Hadoop,Dask 将本地模式视为一等公民。传统的大数据生态系统侧重于在测试时使用本地模式,但 Dask 专注于在单个节点上运行时的良好性能。

另一个显著的文化差异来自打包,许多大数据项目将所有内容整合在一起(例如,Spark SQL、Spark Kubernetes 等一起发布)。Dask 采用更模块化的方法,其组件遵循其自己的开发和发布节奏。Dask 的这种方法可以更快地迭代,但有时会导致库之间的不兼容性。

数据科学

在数据科学生态系统中,最受欢迎的 Python 库之一是 pandas。Apache Spark(及其 Python 伴侣 PySpark)也是最受欢迎的分布式数据科学工具之一。它支持 Python 和 JVM 语言。Spark 最初的 DataFrame 尝试更接近 SQL,而不是您可能认为的 DataFrame。虽然 Spark 已开始与 Koalas 项目 集成 pandas 支持,但我们认为 Dask 对数据科学库 API 的支持是最佳的。³ 除了 pandas API,Dask 还支持 NumPy、scikit-learn 和其他数据科学工具的扩展。

注意

Dask 可以扩展以支持除了 NumPy 和 pandas 之外的数据类型,这正是如何通过 cuDF 实现 GPU 支持的。

并行到分布式 Python

并行计算 指同时运行多个操作,分布式计算 将此扩展到多个机器上的多个操作。并行 Python 涵盖了从多进程到 Celery 等各种工具。⁴ Dask 允许您指定一个任意的依赖图,并并行执行它们。在内部,这种执行可以由单台机器(使用线程或进程)支持,也可以分布在多个工作节点上。

注意

许多大数据工具具有类似的低级任务 API,但这些 API 是内部的,不会向我们公开使用,也没有受到故障保护。

Dask 社区库

Dask 的真正力量来自于围绕它构建的生态系统。不同的库建立在 Dask 之上,使您能够在同一框架中使用多个工具。这些社区库之所以如此强大,部分原因在于低级和高级 API 的结合,这些 API 不仅适用于第一方开发。

加速 Python

您可以通过几种不同的方式加速 Python,从代码生成(如 Numba)到针对特殊硬件的库,如 NVidia 的 CUDA(以及 cuDF 类似的包装器)、AMD 的 ROCm 和 Intel 的 MKL。

Dask 本身并不是加速 Python 的库,但您可以与加速 Python 工具一起使用它。为了方便使用,一些社区项目将加速工具(如 cuDF 和 dask-cuda)与 Dask 集成。当与 Dask 一起使用加速 Python 工具时,您需要小心地构造代码,以避免序列化错误(参见 “序列化和 Pickling”)。

注意

加速 Python 库通常使用更“本地”的内存结构,这些结构不容易通过 pickle 处理。

SQL 引擎

Dask 本身没有 SQL 引擎;但是,FugueSQLDask-SQL,和 BlazingSQL 使用 Dask 提供分布式 SQL 引擎。⁵ Dask-SQL 使用流行的 Apache Calcite 项目,该项目支持许多其他 SQL 引擎。BlazingSQL 扩展了 Dask DataFrames 以支持 GPU 操作。cuDF DataFrames 具有略有不同的表示形式。Apache Arrow 使得将 Dask DataFrame 转换为 cuDF 及其相反变得简单直接。

Dask 允许这些不同的 SQL 引擎在内存和计算方面进行扩展,处理比单台计算机内存能容纳的更大数据量,并在多台计算机上处理行。Dask 还负责重要的聚合步骤,将不同机器的结果组合成数据的一致视图。

提示

Dask-SQL 可以从 Dask 无法读取的 Hadoop 生态系统的部分读取数据(例如 Hive)。

工作流调度

大多数组织都需要某种形式的定期工作,从在特定时间运行的程序(例如计算每日或月末财务数据的程序)到响应事件运行的程序。这些事件可以是数据可用(例如每日财务数据运行后)或新邮件到达,或者可以是用户触发的。在最简单的情况下,定期工作可以是单个程序,但通常情况下比这更复杂。

如前所述,您可以在 Dask 中指定任意图形,如果选择的话,可以使用 Dask 编写工作流程。您可以调用系统命令并解析其结果,但仅仅因为您可以做某事并不意味着它将是有趣或简单的。

大数据生态系统中的工作流调度的家喻户晓的名字⁶ 是 Apache Airflow。虽然 Airflow 拥有一套精彩的操作器集合,使得表达复杂任务类型变得容易,但它以难以扩展而著称。⁷ Dask 可以用于运行 Airflow 任务。或者,它可以用作其他任务调度系统(如 Prefect)的后端。Prefect 旨在将类似 Airflow 的功能带到 Dask,具有一个大型预定义的任务库。由于 Prefect 从开始就将 Dask 作为执行后端,因此它与 Dask 的集成更紧密,开销更低。

注意

少数工具涵盖了完全相同的领域,最相似的工具是 Ray。Dask 和 Ray 都暴露了 Python API,在需要时有底层扩展。有一个 GitHub 问题,其中两个系统的创作者比较了它们的相似之处和差异。从系统角度来看,Ray 和 Dask 之间的最大区别在于状态处理、容错性和集中式与分散式调度。Ray 在 C++ 中实现了更多的逻辑,这可能会带来性能上的好处,但也更难阅读。从用户角度来看,Dask 更加注重数据科学,而 Ray 强调分布式状态和 actor 支持。Dask 可以使用 Ray 作为调度的后端。

虽然 Dask 是很多东西,但它不是你可以挥舞在代码上使其更快的魔术棒。Dask 在某些地方具有兼容的 API,但误用它们可能导致执行速度变慢。Dask 不是代码重写或即时编译(JIT)工具;相反,Dask 允许你将这些工具扩展到集群上运行。Dask 着重于 Python,并且可能不适合与 Python 集成不紧密的语言(如 Go)扩展。Dask 没有内置的目录支持(例如 Hive 或 Iceberg),因此从存储在目录中的表中读取和写入数据可能会带来挑战。

Dask 是扩展你的分析 Python 代码的可能选项之一。它涵盖了从单台计算机上的多个核心到数据中心的各种部署选项。与许多类似领域的其他工具相比,Dask 采用了模块化的方法,这意味着理解其周围的生态系统和库是至关重要的。选择正确的软件扩展取决于你的代码、生态系统、数据消费者以及项目的数据源。我们希望我们已经说服你,值得在下一章节中稍微尝试一下 Dask。

¹ 不是 所有 Python 代码;例如,Dask 在扩展 Web 服务器(从 Web Socket 需求来看非常有状态)方面是一个不好的选择。

² 除了非均匀内存访问(NUMA)系统。

³ 当然,意见有所不同。例如,参见 “单节点处理 — Spark、Dask、Pandas、Modin、Koalas Vol. 1”“基准测试:Koalas(PySpark)和 Dask”,以及 “Spark vs. Dask vs. Ray”

⁴ Celery,通常用于后台作业管理,是一个异步任务队列,也可以分割和分发工作。但它比 Dask 低级,并且没有与 Dask 相同的高级便利性。

⁵ BlazingSQL 不再维护,尽管其概念很有趣,可能会在其他项目中找到用武之地。

⁶ 假设家庭比较书呆子。

⁷ 每小时进行一千项任务,需要进行大量调整和手动考虑;参见“将 Airflow 扩展到 1000 任务/小时”

⁸ 或者,换个角度看,Ray 能够利用 Dask 提供数据科学功能。

我们非常高兴您决定通过尝试来探索是否 Dask 是适合您的系统。在本章中,我们将专注于在本地模式下启动 Dask。使用这种方式,我们将探索一些更为简单的并行计算任务(包括大家喜爱的单词统计)。¹

在本地安装 Dask 相对来说是比较简单的。如果您想要在多台机器上运行,当您从 conda 环境(或 virtualenv)开始时,通常会更容易。这使得您可以通过运行 pip freeze 来确定您依赖的软件包,在扩展时确保它们位于所有工作节点上。

虽然您可以直接运行 pip install -U dask,但我们更倾向于使用 conda 环境,因为这样更容易匹配集群上的 Python 版本,这使得您可以直接连接本地机器到集群。² 如果您的机器上还没有 conda,Miniforge 是一个快速好用的方式来在多个平台上安装 conda。在新的 conda 环境中安装 Dask 的过程显示在 Example2-1 中。

Example 2-1. 在新的 conda 环境中安装 Dask
conda create -n dask python=3.8.6 mamba -yconda activate daskmamba install --yes python==3.8.6 cytoolz dask==2021.7.0 numpy \ pandas==1.3.0 beautifulsoup4 requests

在这里,我们安装的是特定版本的 Dask,而不仅仅是最新版本。如果您计划稍后连接到集群,选择与集群上安装的相同版本的 Dask 将非常有用。

注意

您不必在本地安装 Dask。有一个带有 Dask 的 BinderHub 示例 和分布式选项,包括 Dask 的创建者提供的一个,您可以使用这些选项来运行 Dask,以及其他提供者如 SaturnCloud。尽管如此,即使最终使用了这些服务之一,我们还是建议在本地安装 Dask。

现在您已经在本地安装了 Dask,是时候通过其各种 API 版本的“Hello World”来尝试了。开始 Dask 的选项有很多。目前,您应该使用 LocalCluster,如 Example2-2 中所示。

Example 2-2. 使用 LocalCluster 启动 Dask
import daskfrom dask.distributed import Clientclient = Client() # Here we could specify a cluster, defaults to local mode

任务 Hello World

Dask 的核心构建块之一是 dask.delayed,它允许您并行运行函数。如果您在多台机器上运行 Dask,这些函数也可以分布(或者说散布)到不同的机器上。当您用 dask.delayed 包装一个函数并调用它时,您会得到一个代表所需计算的“延迟”对象。当您创建了一个延迟对象时,Dask 只是记下了您可能希望它执行的操作。就像懒惰的青少年一样,您需要明确告知它。您可以通过 dask.submit 强制 Dask 开始计算值,这会产生一个“future”。您可以使用 dask.compute 来启动计算延迟对象和 futures,并返回它们的值。³

睡眠任务

通过编写一个意图上慢的函数,比如调用sleepslow_task,可以轻松地看到性能差异。然后,您可以通过在几个元素上映射该函数,使用或不使用dask.delayed,来比较 Dask 与“常规”Python 的性能,如示例2-3 所示。

示例 2-3. 睡眠任务
import timeitdef slow_task(x): import time time.sleep(2) # Do something sciency/business return xthings = range(10)very_slow_result = map(slow_task, things)slowish_result = map(dask.delayed(slow_task), things)slow_time = timeit.timeit(lambda: list(very_slow_result), number=1)fast_time = timeit.timeit( lambda: list( dask.compute( *slowish_result)), number=1)print("In sequence {}, in parallel {}".format(slow_time, fast_time))

当我们运行这个例子时,我们得到了In sequence 20.01662155520171, in parallel 6.259156636893749,这显示了 Dask 可以并行运行部分任务,但并非所有任务。⁴

嵌套任务

dask.delayed的一个很好的特点是您可以在其他任务内启动任务。⁵这的一个简单的现实世界例子是网络爬虫,在这个例子中,当您访问一个网页时,您希望从该页面获取所有链接,如示例2-4 所示。

示例 2-4. 网络爬虫
@dask.delayeddef crawl(url, depth=0, maxdepth=1, maxlinks=4): links = [] link_futures = [] try: import requests from bs4 import BeautifulSoup f = requests.get(url) links += [(url, f.text)] if (depth > maxdepth): return links # base case soup = BeautifulSoup(f.text, 'html.parser') c = 0 for link in soup.find_all('a'): if "href" in link: c = c + 1 link_futures += crawl(link["href"], depth=(depth + 1), maxdepth=maxdepth) # Don't branch too much; we're still in local mode and the web is # big if c > maxlinks: break for r in dask.compute(link_futures): links += r return links except requests.exceptions.InvalidSchema: return [] # Skip non-web linksdask.compute(crawl("http://holdenkarau.com/"))
注意

实际上,幕后仍然涉及一些中央协调(包括调度器),但以这种嵌套方式编写代码的自由性非常强大。

我们在“任务依赖关系”中涵盖了其他类型的任务依赖关系。

分布式集合

除了低级任务 API 之外,Dask 还有分布式集合。这些集合使您能够处理无法放入单台计算机的数据,并在其上自然分发工作,这被称为数据并行性。Dask 既有称为bag的无序集合,也有称为array的有序集合。Dask 数组旨在实现一些 ndarray 接口,而 bags 则更专注于函数式编程(例如mapfilter)。您可以从文件加载 Dask 集合,获取本地集合并进行分发,或者将dask.delayed任务的结果转换为集合。

在分布式集合中,Dask 使用分区来拆分数据。分区用于降低与操作单个行相比的调度成本,详细信息请参见“分区/分块集合”。

Dask 数组

Dask 数组允许您超越单个计算机内存或磁盘容量的限制。Dask 数组支持许多标准 NumPy 操作,包括平均值和标准差等聚合操作。Dask 数组中的from_array函数将类似本地数组的集合转换为分布式集合。示例2-5 展示了如何从本地数组创建分布式数组,然后计算平均值。

示例 2-5. 创建分布式数组并计算平均值
import dask.array as dadistributed_array = da.from_array(list(range(0, 1000)))avg = dask.compute(da.average(distributed_array))

与所有分布式集合一样,Dask 数组上的昂贵操作与本地数组上的操作并不相同。在下一章中,您将更多地了解 Dask 数组的实现方式,并希望能更好地直觉到它们的性能。

创建一个分布式集合从本地集合使用分布式计算的两个基本构建块,称为分散-聚集模式。虽然原始数据集必须来自本地计算机,适合单台机器,但这已经扩展了您可以使用的处理器数量,以及您可以利用的中间内存,使您能够更好地利用现代云基础设施和扩展。一个实际的用例可能是分布式网络爬虫,其中要爬行的种子 URL 列表可能是一个小数据集,但在爬行时需要保存的内存可能是数量级更大,需要分布式计算。

Dask 包和词频统计

Dask 包实现了比 Dask 数组更多的函数式编程接口。大数据的“Hello World”是词频统计,使用函数式编程接口更容易实现。由于您已经编写了一个爬虫函数,您可以使用from_delayed函数将其输出转换为 Dask 包(参见示例2-6)。

示例 2-6. 将爬虫函数的输出转换为 Dask 包
import dask.bag as dbgithubs = [ "https://github.com/scalingpythonml/scalingpythonml", "https://github.com/dask/distributed"]initial_bag = db.from_delayed(map(crawl, githubs))

现在您有了一个 Dask 包集合,您可以在其上构建每个人最喜欢的词频示例。第一步是将您的文本包转换为词袋,您可以通过使用map来实现(参见示例2-7)。一旦您有了词袋,您可以使用 Dask 的内置frequency方法(参见示例2-8),或者使用函数转换编写自己的frequency方法(参见示例2-9)。

示例 2-7. 将文本包转换为词袋
words_bag = initial_bag.map( lambda url_contents: url_contents[1].split(" ")).flatten()
示例 2-8. 使用 Dask 的内置frequency方法
dask.compute(words_bag.frequencies())
示例 2-9. 使用函数转换编写自定义frequency方法
def make_word_tuple(w): return (w, 1)def get_word(word_count): return word_count[0]def sum_word_counts(wc1, wc2): return (wc1[0], wc1[1] + wc2[1])word_count = words_bag.map(make_word_tuple).foldby(get_word, sum_word_counts)

在 Dask 包上,foldbyfrequency和许多其他的归约返回一个单分区包,这意味着归约后的数据需要适合单台计算机。Dask DataFrame 处理归约方式不同,没有同样的限制。

Dask DataFrame(Pandas/人们希望大数据是什么)

Pandas 是最流行的 Python 数据库之一,而 Dask 有一个 DataFrame 库,实现了大部分 Pandas API。由于 Python 的鸭子类型,您通常可以在 Pandas 的位置使用 Dask 的分布式 DataFrame 库。不是所有的 API 都会完全相同,有些部分没有实现,所以请确保您有良好的测试覆盖。

警告

您在使用 Pandas 时的慢和快的直觉并不适用。我们将在“Dask DataFrames”中进一步探讨。

为了演示您如何使用 Dask DataFrame,我们将重新编写示例 2-6 到 2-8 来使用它。与 Dask 的其他集合一样,您可以从本地集合、未来数据或分布式文件创建 DataFrame。由于您已经创建了一个爬虫函数,您可以使用 from_delayed 函数将其输出转换为 Dask bag。您可以使用像 explodevalue_counts 这样的 pandas API,而不是使用 mapfoldby,如 示例 2-10 所示。

示例 2-10. DataFrame 单词计数
import dask.dataframe as dd@dask.delayeddef crawl_to_df(url, depth=0, maxdepth=1, maxlinks=4): import pandas as pd crawled = crawl(url, depth=depth, maxdepth=maxdepth, maxlinks=maxlinks) return pd.DataFrame(crawled.compute(), columns=[ "url", "text"]).set_index("url")delayed_dfs = map(crawl_to_df, githubs)initial_df = dd.from_delayed(delayed_dfs)wc_df = initial_df.text.str.split().explode().value_counts()dask.compute(wc_df)

在本章中,您已经在本地机器上成功运行了 Dask,并且看到了大部分 Dask 内置库的不同“Hello World”(或入门)示例。随后的章节将更详细地探讨这些不同的工具。

现在您已经在本地机器上成功运行了 Dask,您可能想跳转到 第十二章 并查看不同的部署机制。在大多数情况下,您可以在本地模式下运行示例,尽管有时速度可能会慢一些或规模较小。然而,下一章将讨论 Dask 的核心概念,即将要介绍的一个示例强调了在多台机器上运行 Dask 的好处,并且在集群上探索通常更容易。如果您没有可用的集群,您可能希望使用类似 MicroK8s 的工具设置一个模拟集群。

¹ 单词计数可能是一个有些陈旧的例子,但它是一个重要的例子,因为它涵盖了既可以通过最小的协调完成的工作(将文本分割成单词),也可以通过多台计算机之间的协调完成的工作(对单词求和)。

² 以这种方式部署您的 Dask 应用程序存在一些缺点,如 第十二章 中所讨论的,但它可以是一种极好的调试技术。

³ 只要它们适合内存。

⁴ 当我们在集群上运行时,性能会变差,因为与小延迟相比,将任务分发到远程计算机存在一定的开销。

⁵ 这与 Apache Spark 十分不同,后者只有驱动程序/主节点可以启动任务。

现在您已经使用 Dask 运行了您的前几个任务,是时候了解一下幕后发生的事情了。根据您是在本地使用 Dask 还是分布式使用,行为可能会有所不同。虽然 Dask 很好地抽象了在多线程或多服务器上运行的许多细节,但深入了解 Dask 的工作原理将帮助您更好地决定何时以及如何使用它。

要熟悉 Dask,您需要了解:

  • Dask 能够运行的部署框架,以及其优势和劣势

  • Dask 能够读取的数据类型,以及如何在 Dask 中与这些数据类型进行交互

  • Dask 的计算模式,以及如何将您的想法转化为 Dask 代码

  • 如何监控和排查故障

在本章中,我们将介绍每一个概念,并在本书的其余部分进行扩展。

Dask 有许多不同的执行后端,但我们发现最容易将它们归为两组:本地和分布式。使用本地后端,您的规模受限于单台计算机所能处理的范围。本地后端还具有诸如避免网络开销、更简单的库管理和更低的成本等优势。¹ Dask 的分布式后端有多种部署选项,从 Kubernetes 等集群管理器到作业队列式系统。

本地后端

Dask 的三个本地后端是单进程、多线程和多进程。单进程后端没有并行性,主要用于验证问题是否由并发引起。多线程和多进程后端适合数据规模较小或复制成本高于计算时间的问题。

提示

如果未配置特定的本地后端,Dask 将根据您正在使用的库选择后端。

本地多线程调度器能够避免需要序列化数据和进程间通信成本。多线程后端适用于大部分计算发生在 Python 之外的本地代码的任务。这对于许多数值库(如 pandas 和 NumPy)是适用的。如果您的情况也是如此,您可以配置 Dask 使用多线程,如 示例3-1 所示。

示例 3-1. 配置 Dask 使用多线程
dask.config.set(scheduler='threads')

本地多进程后端,如 示例3-2 所示,与多线程相比有一些额外的开销,尽管在 Unix 和类 Unix 系统上可以减少这些开销。² 多进程后端通过启动单独的进程来避免 Python 的全局解释器锁。启动新进程比启动新线程更昂贵,而且 Dask 需要序列化在进程之间传输的数据。³

示例 3-2. 配置 Dask 使用多进程后端
dask.config.set(scheduler='processes')

如果您在运行 Unix 系统上,可以使用 forkserver,如 示例 3-3,这将减少每个 Python 解释器启动的开销。使用 forkserver 不会减少通信开销。

示例 3-3. 配置 Dask 使用多进程 forkserver
dask.config.set({"multiprocessing.context": "forkserver", "scheduler": "processes"})

此优化通常不适用于 Windows。

Dask 的本地后端旨在提高性能,而不是测试您的代码是否能在分布式调度器上运行。要测试您的代码能否远程运行,应该使用带有 LocalCluster 的 Dask 分布式调度器。

分布式(Dask 客户端和调度器)

虽然 Dask 在本地可以很好地工作,但其真正的力量来自于分布式调度器,您可以将问题扩展到多台计算机上。由于物理和财务限制限制了可以放入一台机器的计算能力、存储和内存的量,因此使用多台计算机通常是最具成本效益的解决方案(有时甚至是唯一的解决方案)。分布式计算并非没有缺点;正如 Leslie Lamport 所说,“一个分布式系统是指你甚至都不知道存在的计算机的故障可能使你自己的计算机无法使用。”虽然 Dask 在减少这些故障方面做了很多工作(参见 “容错性”),但是在转向分布式系统时,您需要接受一些复杂性增加。

Dask 有一个分布式调度器后端,它可以与许多不同类型的集群进行通信,包括 LocalCluster。每种类型的集群都在其自己的库中得到支持,这些库安排了调度器⁴,而 Dask 客户端则连接到这些调度器。使用分布式抽象 dask.distributed 可以使您在任何时候都可以在不同类型的集群之间移植,包括本地集群。如果您不使用 dask.distributed,Dask 也可以在本地计算机上运行得很好,此时您将使用 Dask 库提供的默认单机调度器。

Dask 客户端是您进入 Dask 分布式调度器的入口。在本章中,我们将使用 Dask 与 Kubernetes 集群;如果您有其他类型的集群或需要详细信息,请参见 第十二章。

自动扩展

使用自动扩展,Dask 可以根据您要求运行的任务增加或减少使用的计算机/资源⁵。例如,如果您有一个程序,使用许多计算机计算复杂的聚合,但后续大部分操作在聚合数据上进行,则在聚合后,您需要的计算机数量可能会大幅减少。许多工作负载,包括机器学习,不需要在整个时间段内使用相同数量的资源/计算机。

Dask 的某些集群后端,包括 Kubernetes,支持自动缩放,Dask 称之为自适应部署。自动缩放主要在共享集群资源或在云提供商上运行时有用,后者的底层资源是按小时计费的情况下使用。

Dask 客户端的重要限制

Dask 的客户端不具备容错性,因此,虽然 Dask 能够处理其工作节点的故障,但如果客户端与调度器之间的连接中断,您的应用程序将会失败。对此的一个常见解决方法是在与调度器相同的环境中调度客户端,尽管这样做会在某种程度上降低将客户端和调度器作为独立组件的实用性。

分布式集群中的库和依赖项

Dask 之所以如此强大的一部分原因是它所在的 Python 生态系统。虽然 Dask 将我们的代码 pickle 或序列化(请参阅“序列化和 Pickling”),并将其发送到工作节点,但这并不包括我们使用的库。⁶ 要利用该生态系统,您需要能够使用其他库。在探索阶段,常常会在运行时安装包,因为您发现需要它们。

PipInstall 工作节点插件接受一个包列表,并在所有工作节点上在运行时安装它们。回顾 Example2-4,要安装 bs4,您将调用 distributed.diagnostics.plugin.PipInstall(["bs4"])。然后由 Dask 启动的任何新工作节点都需要等待包被安装。PipInstall 插件非常适合在您发现需要哪些包时进行快速原型设计。您可以将 PipInstall 视为在笔记本中使用 !pip install 的虚拟环境替代方案。

为避免每次启动新工作节点时都需要安装软件包的缓慢性能,您应尝试预先安装您的库。每个集群管理器(例如,YARN、Kubernetes、Coiled、Saturn 等)都有自己的方法来管理依赖关系。这可以在运行时或设置时进行,其中包已经被预先安装。关于不同集群管理器的具体细节,请参阅第十二章。

例如,在 Kubernetes 中,默认启动脚本会检查某些关键环境变量的存在(EXTRA_APT_PACKAGESEXTRA_CONDA_PACKAGESEXTRA_PIP_PACKAGES),结合自定义的工作节点规范,可以在运行时添加依赖项。其中一些,例如 Coiled 和 Kubernetes,允许在为工作节点构建映像时添加依赖项。另一些,例如 YARN,使用预先分配的 conda/virtual 环境包装。

警告

在所有工作节点和客户端上安装相同版本的 Python 和库非常重要。不同版本的库可能会导致彻底失败或更微妙的数据正确性问题。

在理解程序执行的第一步应该是使用 Dask 的诊断 UI。该 UI 允许您查看 Dask 正在执行的操作,工作线程/进程/计算机的数量,内存利用信息等等。如果您在本地运行 Dask,则很可能会在http:​//localhost:8787找到该 UI。

如果你正在使用 Dask 客户端连接到集群,UI 将运行在调度器节点上。你可以从client.dashboard_link获取仪表板的链接。

提示

对于远程笔记本用户,调度器节点的主机名可能无法直接从您的计算机访问。一种选择是使用 Jupyter 代理;例如,可以访问http://jupyter.example.com/user/username/proxy/dask-head-4c81d51e-3.jhub:8787/status来访问端点dask-head-4c81d51e-3.jhub:8787/status

图3-1 显示了本章示例中运行时的 Dask UI。

Python-Dask-扩展指南-早期发布--全- - 绝不原创的飞龙 - 博客园 (2)

图 3-1. Dask UI (数字,彩色版本)

该 UI 允许您查看 Dask 的执行情况以及存储在工作节点上的内容,并探索执行图。我们将在“visualize”中重新访问执行图。

分布式和并行系统依赖于序列化,在 Python 中有时称为pickling,用于在进程之间共享数据和函数/代码。Dask 使用各种序列化技术来匹配使用情况,并提供扩展钩子以在默认情况不满足需求时进行扩展。

警告

我们在序列化失败(出现错误)时往往会考虑得更多,但同样重要的是可能会出现序列化了比实际需要更多数据的情况,或者数据量如此之大以至于分布式处理不再具备优势。

Cloudpickle 序列化了 Dask 中的函数和通用 Python 类型。大多数 Python 代码不依赖于序列化函数,但集群计算经常需要。Cloudpickle 是一个专为集群计算设计的项目,能够序列化和反序列化比 Python 内置的 pickle 更多的函数。

警告

Dask 具有自己扩展序列化的能力,但是注册方法并不会自动发送到工作节点,并且并非总是使用。⁷

Dask 为 NumPy 数组、稀疏数组和 cuPY 构建了内置特殊处理。这些序列化通常比默认的序列化器更节省空间。当您创建一个包含这些类型且不需要任何特殊初始化的类时,应从dask.distributed.protocol中调用register_generic(YourClass)以利用 Dask 的特殊处理能力。

如果你有一个不能序列化的类,如示例3-4,你可以对其进行包装以添加序列化函数,如示例3-5 所示。

示例3-4. Dask 无法序列化
class ConnectionClass: def __init__(self, host, port): import socket self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.socket.connect((host, port))@dask.delayeddef bad_fun(x): return ConnectionClass("www.scalingpythonml.com", 80)# Fails to serializeif False: dask.compute(bad_fun(1))
示例3-5. 自定义序列化
class SerConnectionClass: def __init__(self, conn): import socket self.conn = conn def __getstate__(self): state_dict = { "host": self.conn.socket.getpeername()[0], "port": self.conn.socket.getpeername()[1]} return state_dict def __setsate__(self, state): self.conn = ConnectionClass(state["host"], state["port"])

如果您控制原始类,还可以直接添加 getstate/setstate 方法而不是包装它。

注意

Dask 自动尝试压缩序列化数据,通常会提高性能。您可以通过将 distributed.comm.compression 设置为 None 来禁用此功能。

分区使您能够控制用于处理数据的任务数量。如果有数十亿行数据,使用每行一个任务将意味着您花费更多时间在任务调度上而非实际工作本身。了解分区是能够最有效地使用 Dask 的关键。

Dask 在其各个集合中为分区使用略有不同的术语。在 Dask 中,分区影响数据在集群上的位置,而对于您的问题来说,选择合适的分区方式可以显著提高性能。分区有几个不同的方面,如每个分区的大小、分区的数量以及可选的属性,如分区键和排序与否。

分区的数量和大小密切相关,并影响最大并行性能。分区太小或数量过多会导致 Dask 在调度任务而非运行任务时花费更多时间。分区大小的一般最佳范围约为 100 MB 到 1 GB,但如果每个元素的计算非常昂贵,较小的分区大小可能会表现更好。

理想情况下,分区大小应该相似,以避免出现滞后情况。分区大小不同的情况称为 skewed。导致数据不平衡的原因有很多,从输入文件大小到键的偏斜(当有键时)。当数据过于不平衡时,您需要重新分区数据。

提示

Dask UI 是查看是否可能有滞后任务的好地方。

Dask 数组

Dask 数组的分区被称为 chunks,表示元素的数量。尽管 Dask 总是知道分区的数量,但当您应用过滤器或加载数据时,Dask 不知道每个分区的大小。索引或切片 Dask 数组需要 Dask 知道分区大小,以便找到包含所需元素的分区。根据创建 Dask 数组的方式,Dask 可能知道每个分区的大小,也可能不知道。我们在第五章中会更详细地讨论这个问题。如果要索引一个 Dask 数组,而 Dask 不知道分区大小,您需要先在数组上调用 compute_chunk_sizes()。当从本地集合创建 Dask 数组时,可以指定目标分区大小,如示例3-6 所示。

示例 3-6. 自定义数组分块大小
distributed_array = da.from_array(list(range(0, 10000)), chunks=10)

分区/分块不一定是静态的,rechunk 函数允许您更改 Dask 数组的分块大小。

Dask Bags

Dask bags 的分区称为 partitions。与 Dask 数组不同,由于 Dask bags 不支持索引,因此 Dask 不跟踪每个分区中的元素数量。当使用 scatter 时,Dask 将尝试尽可能地分区数据,但后续的迭代可能会改变每个分区中的元素数量。与 Dask 数组类似,从本地集合创建时,可以指定 bag 的分区数量,只是参数称为 npartitions 而不是 chunks

您可以通过调用 repartition 来更改 bag 中的分区数量,可以指定 npartitions(用于固定数量的分区)或 partition_size(用于每个分区的目标大小)。指定 partition_size 更昂贵,因为 Dask 需要进行额外的计算来确定匹配的分区数量。

当数据具有索引或数据可以通过值进行查找时,可以将数据视为键控。虽然 bags 实现了像 groupBy 这样的键控操作,其中具有相同键的值被合并,但其分区并不考虑键,而是总是在所有分区上执行键控操作。⁸

Dask DataFrames

DataFrames 在分区方面拥有最多的选项。DataFrames 可以具有不同大小的分区,以及已知或未知的分区方式。对于未知的分区方式,数据是分布式的,但 Dask 无法确定哪个分区持有特定的键。未知的分区方式经常发生,因为任何可能改变键值的操作都会导致未知的分区方式。DataFrame 上的 known_divisions 属性允许您查看 Dask 是否知道分区方式,而 index 属性显示了使用的拆分和列。

如果 DataFrame 拥有正确的分区方式,则像 groupBy 这样的操作,通常涉及大量节点间通信,可以通过较少的通信来执行。通过 ID 访问行需要 DataFrame 在该键上进行分区。如果要更改 DataFrame 分区的列,可以调用 set_index 来更改索引。像所有的重新分区操作一样,设置索引涉及在工作节点之间复制数据,称为 shuffle

小贴士

对于数据集来说,“正确”的分区器取决于数据本身以及您的操作。

Shuffles

Shuffling 指的是在不同的工作节点之间转移数据以重新分区数据。Shuffling 可能是显式操作的结果,例如调用 repartition,也可能是隐式操作的结果,例如按键分组数据或执行聚合操作。Shuffle 操作往往比较昂贵,因此尽量减少其需要的频率,并减少其移动的数据量是很有用的。

理解洗牌的最直接的情况是当您明确要求 Dask 重新分区数据时。在这些情况下,您通常会看到多对多的工作器通信,大多数数据需要在网络上传输。这自然比能够在本地处理数据的情况更昂贵,因为网络比 RAM 慢得多。

触发洗牌的另一个重要方式是通过隐式地进行缩减/聚合。在这种情况下,如果可以在移动数据之前应用部分缩减或聚合,Dask 就能够在网络上传输更少的数据,从而实现更快的洗牌。

提示

有时您会看到事物被称为 map-sidereduce-side;这只是洗牌之前和之后的意思。

我们将在接下来的两章中更深入地探讨如何最小化洗牌的影响,介绍聚合。

载入期间的分区

到目前为止,您已经看到如何在从本地集合创建时控制分区,以及如何更改现有分布式集合的分区。从延迟任务创建集合时,分区通常是 1:1 的,每个延迟任务都是自己的分区。当从文件加载数据时,分区变得有些复杂,涉及文件布局和压缩。一般来说,查看已加载数据的分区的做法是调用 bags 的npartitions、数组的chunks或 DataFrames 的index

任务是 Dask 用于实现dask.delayed、futures 和 Dask 集合上的操作的构建块。每个任务代表了 Dask 无法再进一步分解的一小部分计算。任务通常是细粒度的,计算结果时 Dask 会尝试将多个任务组合成单个执行。

惰性评估

大多数 Dask 是惰性评估的,除了 Dask futures。惰性评估将组合计算的责任从您转移到调度程序。这意味着 Dask 将在合适时合并多个函数调用。不仅如此,如果只需要结构的一些部分,Dask 有时能够通过仅评估相关部分(如headtail调用)进行优化。实现惰性评估需要 Dask 构建一个任务图。这个任务图也被用于容错。

与大多数 Dask 不同,futures 是急切地评估的,这限制了在将它们链接在一起时可用的优化,因为调度程序在开始执行第一个 future 时对世界的视图不够完整。Futures 仍然创建任务图,您可以通过在下一节中可视化它们来验证这一点。

与 Dask 的其余部分不同,未来值是急切评估的,这限制了在将它们链接在一起时可用的优化,因为调度程序在执行第一个未来时对世界的视图不完整。未来仍然创建任务图,您可以通过可视化它们来验证,正如我们将在下一节中看到的那样。

任务依赖

除了嵌套任务外,如 “嵌套任务” 中所见,您还可以将 dask.delayed 对象作为另一个延迟计算的输入(参见 示例3-7),Dask 的 submit/compute 函数将为您构建任务图。

示例 3-7. 任务依赖
@dask.delayed()def string_magic(x, y): lower_x = x.lower() lower_y = y.lower() return (lower_x in lower_y) or (lower_y in lower_x)@dask.delayed()def gen(x): return xf = gen("hello world")compute = string_magic(f, f)

现在,当您计算最终组合值时,Dask 将使用其隐式任务图计算所有其他需要的最终函数值。

注意

您不需要传递真实值。例如,如果一个函数更新数据库,而您希望在此之后运行另一个函数,即使您实际上不需要其 Python 返回值,也可以将其用作参数。

通过将延迟对象传递到其他延迟函数调用中,您允许 Dask 重用任务图中的共享节点,从而可能减少网络开销。

visualize

在学习任务图和未来调试时,可视化任务图是一个优秀的工具。visualize 函数在 Dask 库和所有 Dask 对象中都有定义。与在多个对象上单独调用 .visualize 不同,您应该调用 dask.visualize 并传递您计划计算的对象列表,以查看 Dask 如何组合任务图。

你应该立即通过可视化示例 2-6 至 2-9 来尝试这个。当你在 words_bag​.fre⁠quen⁠cies() 上调用 dask.visualize 时,你应该得到类似 图3-2 的结果。

Python-Dask-扩展指南-早期发布--全- - 绝不原创的飞龙 - 博客园 (3)

图3-2. 可视化的单词计数任务图(重新绘制输出)
提示

Dask UI 还显示任务图的可视化表示,无需修改您的代码。

中间任务结果

一旦依赖任务开始执行,中间任务结果通常会立即被删除。当我们需要对相同数据执行多个计算时,这可能不够优化。解决这个问题的一个方法是将所有执行组合到一个对 dask.compute 的调用中,以便 Dask 可以根据需要保留数据。在交互式案例中,这种方法会失败,因为我们事先不知道计算是什么,也会在迭代案例中出现类似的问题。在这些情况下,某种形式的缓存或持久性可能是有益的。您将在本章后面学习如何应用缓存。

任务大小

Dask 使用集中式调度器,这是许多系统的常见技术。但这也意味着,尽管一般任务调度的开销只有 1 毫秒,但随着系统中任务数量的增加,调度器可能会成为瓶颈,开销也会增加。令人反直觉的是,这意味着随着我们系统的扩展,我们可能会从更大、更粗粒度的任务中受益。

当任务图变得太大

有时任务图本身可能对 Dask 处理过多。这个问题可能表现为客户端或调度器上的内存不足异常,或者更常见的是随迭代变慢的作业。最常见的情况是递归算法。一个常见的示例是分布式交替最小二乘法。

遇到任务图过大的情况时的第一步是看看是否可以通过使用更大的工作块或切换算法来减少并行性。例如,如果我们考虑使用递归计算斐波那契数列,更好的选择是使用动态规划或记忆化解决方案,而不是尝试使用 Dask 分发计算任务。

如果你有一个迭代算法,并且没有更好的方法来实现你想要的效果,可以通过定期写入中间结果并重新加载来帮助 Dask。¹⁰ 这样一来,Dask 就不必跟踪创建数据的所有步骤,而只需记住数据的位置。接下来的两章将讨论如何有效地为这些及其他目的编写和加载数据。

提示

在 Spark 中,等价的概念被称为 checkpointing

结合计算

要充分利用 Dask 的图优化,最重要的是以较大的批次提交你的工作。首先,当你在 dask.compute 中阻塞在结果上时,小批次会限制并行性。如果有一个共享的父节点——比如说,同一数据上的两个结果——一起提交计算允许 Dask 共享底层数据的计算。你可以通过在任务列表上调用 visualize 来验证 Dask 能否共享一个公共节点(例如,如果你将示例 2-8 和 2-9 一起可视化,你将在 Figure3-2 中看到共享节点)。

有时你不能一起提交计算,但你仍然知道你想要重用一些数据。在这些情况下,你应该探索持久化。

持久化、缓存和记忆化

持久化允许你在集群中将指定的 Dask 集合保留在内存中。要为将来重用持久化一个集合,只需在集合上调用dask.persist。如果选择持久化,你需要负责告诉 Dask 何时完成对分布式集合的使用。与 Spark 不同,Dask 没有简单的unpersist等效方法;相反,你需要释放每个分区的底层 future,就像在示例 3-8 中所示。

示例 3-8. 使用 Dask 进行手动持久化和内存管理
df.persist# You do a bunch of things on DF# I'm done!from distributed.client import futures_oflist(map(lambda x: x.release(), futures_of(df)))
警告

常见的错误是持久化和缓存那些仅被使用一次或计算成本低廉的东西。

Dask 的本地模式具有基于 cachey 的尽力缓存系统。由于这仅在本地模式下工作,我们不会深入讨论细节,但如果你在本地模式下运行,可以查看本地缓存文档

警告

当你尝试在分布式方式下使用 Dask 缓存时,Dask 不会引发错误;它只是不起作用。因此,在从本地迁移到分布式时,请确保检查 Dask 本地缓存的使用情况。

在像 Dask 这样的分布式系统中,“容错性”通常指的是系统如何处理计算机、网络或程序故障。随着使用计算机数量的增加,容错性变得越来越重要。当你在单台计算机上使用 Dask 时,容错的概念就不那么重要,因为如果你的计算机失败了,就没有什么可恢复的了。然而,当你有数百台机器时,计算机故障的机率就会增加。Dask 的任务图用于提供其容错性。¹¹ 在分布式系统中有许多不同类型的故障,但幸运的是,其中许多可以以相同的方式处理。

当调度器失去与工作节点的连接时,Dask 会自动重试任务。这种重试是通过 Dask 用于惰性评估的计算图来实现的。

警告

Dask 客户端对连接调度器的网络问题不具备容错能力。你可以采用的一种减轻技术是在与调度器相同的网络中运行你的客户端。

在分布式系统中,机器故障是生活中的一个事实。当一个工作节点失败时,Dask 会像处理网络故障一样重新尝试任何必要的任务。然而,Dask 无法从客户端代码的调度器失败中恢复。¹² 因此,在运行于共享环境时,高优先级运行客户端和调度器节点是非常重要的,以避免被抢占。

Dask 会自动重试由于软件失败而退出或崩溃的工作节点。从 Dask 的角度来看,工作节点退出和网络故障看起来是一样的。

IOError 和 OSError 异常是 Dask 将重试的唯二异常类。如果您的工作进程引发其中一个错误,异常将被 pickle 并传输到调度程序。然后 Dask 的调度程序会重试任务。如果您的代码遇到不应重试的 IOError(例如,网页不存在),您需要将其包装在另一个异常中,以防止 Dask 重新尝试它。

由于 Dask 重试失败的计算,因此在处理副作用或更改值时要小心。例如,如果您有一个 Dask transactions 的 bag,并且在 map 的一部分更新数据库,Dask 可能会多次重新执行该 bag 上的某些操作,导致数据库更新多次发生。如果我们考虑从 ATM 取款,就能看出这将导致一些不满意的客户和不正确的数据。相反,如果您需要改变小数据位,请将它们带回本地集合。

如果您的程序遇到其他异常,Dask 将把异常返回到您的主线程。¹³

通过本章,您应该对 Dask 如何扩展您的 Python 代码有了很好的掌握。您现在应该了解分区的基础知识,为什么这很重要,任务大小以及 Dask 对容错的方法。这将有助于您决定何时应用 Dask,并在接下来的几章中深入研究 Dask 的集合库。在下一章中,我们将专注于 Dask 的 DataFrames,因为它们是 Dask 分布式集合中功能最全的。

¹ 除非您为云提供商工作并且计算机几乎是免费的。如果您确实为云提供商工作,请发送给我们云积分。

² 包括 OS X 和 Linux。

³ 这还涉及必须在驱动程序线程中有对象的第二个副本,然后在工作程序中使用。由于 Dask 对其集合进行分片,这通常不会像正常的多处理那样迅速扩展。

⁴ 快速连续说五次。

⁵ 就像许多现实世界的情况一样,增加 Dask 节点比减少更容易。

⁶ 自动挑选和运输库将非常困难,而且也很慢,尽管在某些情况下可以完成。

⁷ 参见 Dask 分布式 GitHub 问题 55612953

⁸ 对于来自数据库的人来说,您可以将其视为 Spark 的 groupBy 的“全扫描”或“全洗牌”。

⁹ 当 Dask 可以优化评估时,这里的情况复杂,但请记住,任务是计算的基本单位,Dask 无法在任务内进一步分解计算。因此,从许多个体任务创建的 DataFrame,当您调用head时,是 Dask 优化的一个很好的候选对象;但对于创建大 DataFrame 的单个任务,Dask 无法内部“深入”分解。

¹⁰ 如果数据集足够小,您也可以进行收集和分散。

¹¹ 在 Spark 中使用的相同技术。

¹² 这在大多数类似系统中很常见。Spark 确实具有从头节点故障中恢复的有限能力,但有许多限制,且不经常使用。

¹³ 对于从 Spark 迁移的用户,此重试行为有所不同。Spark 会对大多数异常进行重试,而 Dask 仅在工作节点退出或出现 IOError 或 OSError 时重试。

虽然 Pandas DataFrame 非常流行,但随着数据规模的增长,它们很快会遇到内存限制,因为它们将整个数据存储在内存中。Pandas DataFrame 具有强大的 API,用于各种数据操作,并且经常是许多分析和机器学习项目的起点。虽然 Pandas 本身没有内置机器学习功能,但数据科学家们经常在新项目的探索阶段的数据和特征准备中使用它。因此,将 Pandas DataFrame 扩展到能够处理大型数据集对许多数据科学家至关重要。大多数数据科学家已经熟悉 Pandas 库,而 Dask 的 DataFrame 实现了大部分 Pandas API,并且增加了扩展能力。

Dask 是最早实现可用子集的 Pandas API 之一,但其他项目如 Spark 已经添加了它们自己的方法。本章假定您已经对 Pandas DataFrame API 有很好的理解;如果没有,您应该查看Python for Data Analysis

由于鸭子类型,你经常可以在只做少量更改的情况下使用 Dask DataFrame 替代 Pandas DataFrame。然而,这种方法可能会有性能缺陷,并且一些功能是不存在的。这些缺点来自于 Dask 的分布式并行性质,它为某些类型的操作增加了通信成本。在本章中,您将学习如何最小化这些性能缺陷,并解决任何缺失功能。

Dask DataFrame 要求您的数据和计算与 Pandas DataFrame 非常匹配。Dask 有用于非结构化数据的 bags,用于数组结构化数据的 arrays,用于任意函数的 Dask 延迟接口,以及用于有状态操作的 actors。如果即使在小规模下您都不考虑使用 Pandas 解决您的问题,那么 Dask DataFrame 可能不是正确的解决方案。

Dask DataFrame 是基于 Pandas DataFrame 构建的。每个分区都存储为一个 Pandas DataFrame。¹ 使用 Pandas DataFrame 作为分区简化了许多 API 的实现。特别是对于基于行的操作,Dask 会将函数调用传递给每个 Pandas DataFrame。

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

数据分析只有在能够访问到数据时才有价值,我们的见解只有在产生行动时才有帮助。由于我们的数据并非全部都在 Dask 中,因此从世界其他地方读取和写入数据至关重要。到目前为止,本书中的示例主要使用了本地集合,但您有更多选择。

Dask 支持读取和写入许多标准文件格式和文件系统。这些格式包括 CSV、HDF、定宽、Parquet 和 ORC。Dask 支持许多标准的分布式文件系统,从 HDFS 到 S3,以及从常规文件系统读取。

对于 Dask 最重要的是,分布式文件系统允许多台计算机读取和写入相同的文件集。分布式文件系统通常在多台计算机上存储数据,这允许存储比单台计算机更多的数据。通常情况下,分布式文件系统也具有容错性(通过复制来实现)。分布式文件系统可能与您习惯的工作方式有重要的性能差异,因此重要的是查看您正在使用的文件系统的用户文档。需要关注的一些内容包括块大小(通常不希望写入比这些更小的文件,因为其余部分是浪费空间)、延迟和一致性保证。

提示

在 Dask 中从常规本地文件读取可能会很复杂,因为文件需要存在于所有工作节点上。如果文件仅存在于主节点上,请考虑将其复制到像 S3 或 NFS 这样的分布式文件系统,或者在本地加载并使用 Dask 的 client.scatter 函数来分发数据(如果数据足够小)。足够小的文件可能表明你还不需要使用 Dask,除非对其进行处理很复杂或很慢。

格式

Dask 的 DataFrame 加载和写入函数以 to_read_ 作为前缀。每种格式都有自己的配置,但通常第一个位置参数是要读取的数据的位置。位置可以是文件的通配符路径(例如 *s3://test-bucket/magic/**)、文件列表或常规文件位置。

注意

通配符路径仅适用于支持目录列表的文件系统。例如,它们在 HTTP 上不起作用。

在加载数据时,正确设置分区数量将加快所有操作的速度。有时无法以正确的分区数加载数据,在这种情况下,您可以在加载后重新分区数据。正如讨论的那样,更多的分区允许更多的并行处理,但也带来非零的开销。不同的格式有略微不同的控制方式。HDF 使用 chunksize,表示每个分区的行数。Parquet 也使用 split_row_groups,它接受一个整数,表示期望从 Parquet 文件中逻辑分区的划分,并且 Dask 将整个数据集分割成这些块,或更少。如果未指定,默认行为是每个分区对应一个 Parquet 文件。基于文本的格式(CSV、固定宽度等)使用 blocksize 参数,其含义与 Parquet 的 chunksize 相同,但最大值为 64 MB。您可以通过加载数据集并查看任务和分区数量随着较小的目标大小增加来验证这一点,就像 示例4-1 中所示。

示例 4-1. 使用 1 KB 块加载 CSV 的 Dask DataFrame
many_chunks = dd.read_csv(url, blocksize="1kb")many_chunks.index

加载 CSV 和 JSON 文件可能比 Parquet 更复杂,而其他自描述数据类型没有编码任何模式信息。Dask DataFrame 需要知道不同列的类型,以正确地序列化数据。默认情况下,Dask 将自动查看前几条记录并猜测每列的数据类型。这个过程称为模式推断,但它可能相当慢。

不幸的是,模式推断并不总是有效。例如,如果尝试从 https​://gender-pay-gap​.ser⁠vice.gov.uk/viewing/download-data/2021 加载英国性别工资差距数据时,如同 示例4-2 中所示,将会出现 “在 pd.read​_csv/pd.read_table 中找到的不匹配的数据类型” 的错误。当 Dask 的列类型推断错误时,您可以通过指定 dtype 参数(每列)来覆盖它,就像 示例4-3 中所示。

示例 4-2. 使用完全依赖推断加载 CSV 的 Dask DataFrame
df = dd.read_csv( "https://gender-pay-gap.service.gov.uk/viewing/download-data/2021")
示例 4-3. 使用指定数据类型加载 CSV 的 Dask DataFrame
df = dd.read_csv( "https://gender-pay-gap.service.gov.uk/viewing/download-data/2021", dtype={'CompanyNumber': 'str', 'DiffMeanHourlyPercent': 'float64'})
注意

在理论上,通过使用 sample 参数并指定更多字节,可以让 Dask 采样更多记录,但目前这并不能解决问题。当前的采样代码并没有严格遵守请求的字节数量。

即使模式推断没有返回错误,完全依赖它也有许多缺点。模式推断涉及对数据的抽样,因此其结果既是概率性的又很慢。在可以的情况下,应使用自描述格式或避免模式推断;这样可以提高数据加载速度并增强可靠性。您可能会遇到的一些常见自描述格式包括 Parquet、Avro 和 ORC。

读取和写入新文件格式是一项繁重的工作,特别是如果没有现成的 Python 库。如果有现成的库,您可能会发现将原始数据读入一个包并使用map函数解析它会更容易,我们将在下一章进一步探讨这一点。

小贴士

Dask 在加载时不会检测排序数据。相反,如果您有预排序数据,在设置索引时添加sorted=true参数可以利用您已经排序的数据,这是您将在下一节中学习的步骤。但是,如果在数据未排序时指定此选项,则可能会导致数据静默损坏。

您还可以将 Dask 连接到数据库或微服务。关系型数据库是一种很棒的工具,通常在简单读写方面表现出色。通常,关系型数据库支持分布式部署,其中数据分割在多个节点上,这在处理大型数据集时经常使用。关系型数据库通常非常擅长处理大规模的事务,但在同一节点上运行分析功能可能会遇到问题。Dask 可用于有效地读取和计算 SQL 数据库中的数据。

您可以使用 Dask 的内置支持通过 SQLAlchemy 加载 SQL 数据库。为了让 Dask 在多台机器上拆分查询,您需要给它一个索引键。通常,SQL 数据库会有一个主键或数字索引键,您可以用于此目的(例如,read_sql_table("customers", index_col="customer_id"))。示例在示例4-4 中展示了这一点。

示例 4-4. 使用 Dask DataFrame 从 SQL 读取和写入数据
from sqlite3 import connectfrom sqlalchemy import sqlimport dask.dataframe as dd#sqlite connectiondb_conn = "sqlite://fake_school.sql"db = connect(db_conn)col_student_num = sql.column("student_number")col_grade = sql.column("grade")tbl_transcript = sql.table("transcripts")select_statement = sql.select([col_student_num, col_grade] ).select_from(tbl_transcript)#read from sql dbddf = dd.read_sql_query(select_stmt, npartitions=4, index_col=col_student_num, con=db_conn)#alternatively, read whole tableddf = dd.read_sql_table("transcripts", db_conn, index_col="student_number", npartitions=4 )#do_some_ETL...#save to dbddf.to_sql("transcript_analytics", uri=db_conn, if_exists='replace', schema=None, index=False )

更高级的与数据库或微服务的连接最好使用包接口并编写自定义加载代码,关于这一点您将在下一章中学到更多。

文件系统

加载数据可能是大量工作和瓶颈,因此 Dask 像大多数其他任务一样进行分布式处理。如果使用 Dask 分布式,每个工作节点必须能够访问文件以并行加载。与将文件复制到每个工作节点不同,网络文件系统允许每个人访问文件。Dask 的文件访问层使用 FSSPEC 库(来自 intake 项目)来访问不同的文件系统。由于 FSSPEC 支持一系列文件系统,因此它不会为每个支持的文件系统安装要求。使用示例4-5 中的代码查看支持的文件系统及需要额外包的文件系统。

示例 4-5. 获取 FSSPEC 支持的文件系统列表
from fsspec.registry import known_implementationsknown_implementations

许多文件系统都需要某种配置,无论是端点还是凭证。通常新的文件系统,比如 MinIO,提供与 S3 兼容的 API,但超载端点并需要额外的配置才能正常运行。使用 Dask,您可以通过 storage​_options 参数来指定读写函数的配置参数。每个人的配置可能会有所不同。² Dask 将使用您的 storage_options 字典作为底层 FSSPEC 实现的关键字参数。例如,我对 MinIO 的 storage_options 如 示例 4-6 所示。

示例 4-6. 配置 Dask 以连接到 MinIO
minio_storage_options = { "key": "YOURACCESSKEY", "secret": "YOURSECRETKEY", "client_kwargs": { "endpoint_url": "http://minio-1602984784.minio.svc.cluster.local:9000", "region_name": 'us-east-1' }, "config_kwargs": {"s3": {"signature_version": 's3v4'}},}

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

索引经常用于过滤数据,仅保留您需要的组件。我们通过查看仅显示所有疫苗接种状态的人的案例率来处理旧金山 COVID-19 数据,如 示例 4-7 所示。

示例 4-7. Dask DataFrame 索引
mini_sf_covid_df = (sf_covid_df [sf_covid_df['vaccination_status'] == 'All'] [['specimen_collection_date', 'new_cases']])

如果您真的需要按行进行位置索引,请通过计算每个分区的大小并使用它来选择所需的分区子集来实现。这非常低效,因此 Dask 避免直接实现它;在执行此操作之前,请做出明智的选择。

正如前一章所述,洗牌是昂贵的。导致洗牌昂贵的主要原因是在进程之间移动数据时的序列化开销,以及与从内存读取数据相比,网络的相对慢速。这些成本会随着被洗牌的数据量增加而增加,因此 Dask 有一些技术来减少被洗牌的数据量。这些技术取决于特定的数据属性或正在执行的操作。

滚动窗口和 map_overlap

触发洗牌的一种情况是滚动窗口,在分区的边缘,您的函数需要其邻居的一些记录。Dask DataFrame 具有特殊的 map_overlap 函数,您可以在其中指定一个后视窗口(也称为向前窗口)和一个前视窗口(也称为向后窗口)来传输行数(可以是整数或时间差)。利用此功能的最简单示例是滚动平均,如 示例 4-8 所示。

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

使用 map_overlap 允许 Dask 仅传输所需的数据。为使此实现正常工作,您的最小分区大小必须大于最大窗口。

警告

Dask 的滚动窗口不会跨多个分区。如果你的 DataFrame 被分区,以至于向后或向前查看大于相邻分区的长度,结果将失败或不正确。Dask 对时间增量向后查看进行验证,但对向前查看或整数向后查看不执行此类检查。

解决 Dask 单分区向前/向后查看的有效但昂贵的技术是repartition你的 Dask DataFrames。

聚合

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

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

提示

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

加快聚合的第一种方法是减少正在进行聚合的列,因为处理速度最快的数据是没有数据。最后,如果可能的话,同时进行多次聚合减少了需要洗牌同样数据的次数。因此,如果需要计算平均值和最大值,应同时计算两者(见示例 4-9)。

示例 4-9. 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的结果并生成最终值。

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

示例 4-10. Dask 自定义聚合
# Write a custom weighted mean, we get either a DataFrameGroupBy# with multiple columns or SeriesGroupBy for each chunkdef 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 / weightsweighted_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 数字,如示例4-11 所示。

示例 4-11. 跨整个 DataFrame 进行聚合
raw_grouped = sf_covid_df.groupby(lambda x: 0)

当存在内置聚合时,它很可能比我们编写的任何内容都要好。有时,部分聚合是部分实现的,例如 Dask 的 HyperLogLog:它仅适用于完整的 DataFrames。您通常可以通过复制chunk函数,使用aggcombine参数以及finalizeaggregate参数来转换简单的聚合。这通过在示例4-12 中移植 Dask 的 HyperLogLog 实现来展示。

示例 4-12. 使用dd.Aggregation包装 Dask 的 HyperLogLog
# Wrap Dask's hyperloglog in dd.Aggregationfrom dask.dataframe import hyperloglogapprox_unique = dd.Aggregation( name='approx_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 函数实现为一个完全的洗牌,这在下一节中有详细介绍。

警告

Dask 在使用 apply 函数时无法应用部分聚合。

完全洗牌和分区

如果 Dask 内部的操作比在本地 DataFrame 中的预期要慢,可能是因为它需要进行完全洗牌。例如,排序就是一个例子,因为在分布式系统中排序通常需要进行洗牌,所以它本质上是昂贵的。在 Dask 中,有时完全洗牌是无法避免的。与完全洗牌本身慢速的相反,您可以使用它们来加速将来在相同分组键上进行的操作。正如在聚合部分提到的那样,触发完全洗牌的一种方式是在不对齐分区的情况下使用 apply 方法。

分区

在重新分区数据时,您最常使用完全洗牌。在处理聚合、滚动窗口或查找/索引时,拥有正确的分区非常重要。正如在滚动窗口部分讨论的那样,Dask 不能做超过一个分区的向前或向后查找,因此需要正确的分区才能获得正确的结果。对于大多数其他操作,错误的分区将减慢作业速度。

Dask 有三种主要方法来控制 DataFrame 的分区:set_indexrepartitionshuffle(参见表4-1)。当将分区更改为新的键/索引时,使用 set_indexrepartition 保持相同的键/索引,但更改了分割。repartitionset_index 使用类似的参数,repartition 不需要索引键名称。一般来说,如果不更改用于索引的列,应该使用 repartitionshuffle 稍有不同,因为它不会产生类似于 groupby 可以利用的已知分区方案。

表 4-1. 控制分区的函数

方法更改索引键设置分区数导致已知分区方案理想使用情况
set_index更改索引键
repartition增加/减少分区数
shuffle键的分布倾斜^(a)
^(a) 为分布哈希键,可以帮助随机分布倾斜数据 如果 键是唯一的(但是集中的)。

为了为 DataFrame 获取正确的分区,第一步是决定是否需要索引。索引在按索引值过滤数据、索引、分组以及几乎任何其他按键操作时都非常有用。其中一种按键操作是 groupby,其中被分组的列可以是一个很好的键候选。如果您在列上使用滚动窗口,该列必须是键,这使得选择键相对容易。一旦确定了索引,您可以使用索引列名称调用 set_index(例如,set_index("PostCode"))。这通常会导致 shuffle,因此现在是调整分区大小的好时机。

提示

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

选择了键之后,下一个问题是如何设置分区大小。通常适用于这里的建议是 “分区/块集合”:尝试保持足够的分区以使每台机器保持忙碌,但请记住大约在 100 MB 到 1 GB 的一般甜点。如果给定目标分区数,Dask 通常会计算出相当均匀的分割。⁵ 幸运的是,set_index 也将接受 npartitions。要通过邮政编码重新分区数据,使用 10 个分区,您可以添加 set_index("PostCode", npartitions=10);否则,Dask 将默认使用输入分区数。

如果您计划使用滚动窗口,您可能需要确保每个分区覆盖了正确大小的键范围。作为 set_index 的一部分,您需要计算自己的分区来确保每个分区具有正确范围的记录。分区被指定为列表,从第一个分区的最小值到最后一个分区的最大值。在构建由 Pandas DataFrame 组成的 Dask DataFrame 的分区 [0, 100) [100, 200), [200, 300), [300, 500),您可以编写 df.set_index("NumEmployees", divisions=[0, 100, 200, 300, 500])。类似地,为了支持从 COVID-19 疫情开始到今天最多七天的滚动窗口的日期范围,请参见 Example4-13。

示例 4-13. 使用 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,包括用于滚动时间窗口,假设您的分区索引是单调递增的。⁶

到目前为止,你必须指定分区的数量或具体的分割点,但你可能想知道 Dask 是否可以自己找出这些。幸运的是,Dask 的 repartition 函数有能力为给定的目标大小选择分割点,就像在 Example4-14 中展示的那样。然而,这样做会有一个不可忽视的成本,因为 Dask 必须评估 DataFrame 以及重新分区本身。

Example 4-14. Dask DataFrame 自动分区
reparted = indexed.repartition(partition_size="20kb")
警告

截至本文撰写时,Dask 的set_index有一个类似的partition_size参数,但仅适用于减少分区的数量。

正如你在本章开头看到的,当写入一个 DataFrame 时,每个分区都有其自己的文件,但有时这会导致文件过大或过小。有些工具只能接受一个文件作为输入,因此你需要将所有内容重新分区为单个分区。其他时候,数据存储系统被优化为特定的文件大小,例如 HDFS 的默认块大小为 128 MB。好消息是,诸如repartitionset_index的技术已经为你解决了这些问题。

Dask 的map_partitions函数将一个函数应用于底层 pandas DataFrame 的每个分区,结果也是一个 pandas DataFrame。使用map_partitions实现的函数是尴尬的并行,因为它们不需要任何数据的跨 worker 传输。⁷ Dask 实现了mapmap_partitions,以及许多逐行操作。如果你想使用一个你找不到的逐行操作,你可以自己实现,就像在 Example4-15 中展示的那样。

Example 4-15. 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# informationnew_df.clear_divisions()

你并不局限于调用 pandas 内置函数。只要你的函数接受并返回一个 DataFrame,你几乎可以在map_partitions中做任何你想做的事情。

完整的 pandas API 在本章中太长无法涵盖,但如果一个函数可以在不知道前后行的情况下逐行操作,那么它可能已经在 Dask DataFrame 中使用map_partitions实现了。

当在一个 DataFrame 上使用map_partitions时,你可以改变每行的任何内容,包括它分区的键。如果你改变了分区键中的值,你必须clear_divisions()清除结果 DataFrame 上的分区信息,或者set_index指定正确的索引,这个你将在下一节学到更多。

警告

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

Pandas 和 Dask 有四个常用的用于组合 DataFrame 的函数。在根上是 concat 函数,它允许您在任何轴上连接 DataFrames。由于涉及到跨 worker 的通信,Dask 中的 DataFrame 连接通常较慢。另外三个函数是 joinmergeappend,它们都在 concat 的基础上针对常见情况实现了特殊处理,并具有略微不同的性能考虑。在处理多个 DataFrame 时,通过良好的分区和键选择,尤其是分区数量,可以显著提升性能。

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

提示

Dask 具有特殊逻辑,可加速多个 DataFrame 的连接操作,因此在大多数情况下,您可以通过执行 a.join([b, c, d, e]) 而不是 a.join(b).join(c).join(d).join(e) 获得更好的性能。但是,如果您正在执行与小数据集的左连接,则第一种语法可能更有效。

当您按行合并或 concat DataFrames(类似于 SQL UNION)时,性能取决于被合并 DataFrames 的分区是否 well ordered。如果一系列 DataFrame 的分区是良好排序的,那么所有分区都是已知的,并且前一个 DataFrame 的最高分区低于下一个 DataFrame 的最低分区,则我们称这些 DataFrame 的分区是良好排序的。如果任何输入具有未知分区,Dask 将产生一个没有已知分区的输出。对于所有已知分区,Dask 将行合并视为仅元数据的更改,并且不会执行任何数据重排。这要求分区之间没有重叠。还有一个额外的 interleave_partitions 参数,它将行合并的连接类型更改为无输入分区限制的连接类型,并导致已知的分区结果。具有已知分区的 Dask DataFrame 可以通过键支持更快的查找和操作。

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

警告

在处理具有未知分区的 DataFrame 时,不要使用 Dask 的concat,因为它可能会返回不正确的结果。⁸

多 DataFrame 内部

Dask 使用四种技术——哈希、广播、分区和stack_partitions——来组合 DataFrame,每种技术的性能差异很大。这四个函数与您从中选择的连接函数并不一一对应。相反,Dask 根据索引、分区和请求的连接类型(例如外部/左/内部)选择技术。三种基于列的连接技术是哈希连接、广播连接和分区连接。在进行基于行的组合(例如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_partitions 技术,除非所有输入 DataFrame 的分区都已知,它们没有被很好地排序,并且你要求 Dask interleave_partitions。在输出中,stack_partitions 技术只有在输入分区已知且有序时才能提供已知分区。如果所有分区都已知但排序不好,并且你设置了 interleave​_parti⁠tions,Dask 将使用分区连接。虽然这种方法相对廉价,但并非免费,而且可能导致分区数量过多,需要重新分区。

缺失功能

并非所有的多数据框操作都已实现,比如 compare,这将我们引入关于 Dask DataFrames 限制的下一节。

Dask 的 DataFrame 实现了大部分但并非全部的 pandas DataFrame API。由于开发时间的原因,Dask 中未实现部分 pandas API。其他部分则未使用,以避免暴露可能意外缓慢的 API。

有时候 API 只是缺少一些小部分,因为 pandas 和 Dask 都在积极开发中。一个例子是来自 Example2-10 的 split 函数。在本地 pandas 中,你可以调用 split(expand=true) 而不是 split().explode()。如果你有兴趣,这些缺失部分可以是你参与并 贡献到 Dask 项目 的绝佳机会。

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

虽然大部分 pandas DataFrame API 可以正常工作,但在你切换到 Dask DataFrame 之前,确保有充分的测试覆盖来捕捉它不适用的情况是非常重要的。

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

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

Dask 的惰性评估,由其谱系图支持,通常是有益的,允许它自动组合步骤。然而,当图变得过大时,Dask 可能会难以管理,通常表现为驱动进程或笔记本运行缓慢,有时会出现内存不足的异常。幸运的是,您可以通过将 DataFrame 写出并重新读取来解决这个问题。一般来说,Parquet 是这样做的最佳格式,因为它在空间上高效且自我描述,因此无需进行模式推断。

惰性评估的另一个挑战是如果您想多次重用一个元素。例如,假设您想加载几个 DataFrame,然后计算多个信息片段。您可以要求 Dask 通过运行 client.persist(collection) 将集合(包括 DataFrame、Series 等)保存在内存中。并非所有重新计算的数据都需要避免;例如,如果加载 DataFrame 很快,不持久化它们可能是可以接受的。

警告

与 Apache Spark 明显不同,像 Dask 的其他函数一样,persist() 不会修改 DataFrame — 如果您在其上调用函数,数据仍然会重新计算。

由于性能原因,Dask DataFrame 的各个部分行为可能与本地 DataFrame 稍有不同:

reset_index

每个分区的索引将在零点重新开始。

kurtosis

此函数不会过滤掉 NaN,并使用 SciPy 的默认值。

concat

不同于强制转换类别类型,每个类别类型都会扩展到与其连接的所有类别的并集。

sort_values

Dask 仅支持单列排序。

连接多个 DataFrame

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

在将代码移植到使用 Dask DataFrame 时,您应特别注意任何时候使用这些函数,因为它们可能不会完全在您预期的轴上工作。首先进行小范围测试,并测试数字的正确性,因为问题通常很难追踪。

在将现有的 pandas 代码移植到 Dask 时,请考虑使用本地单机版本生成测试数据集,以便与结果进行比较,以确保所有更改都是有意的。

Dask DataFrame 已经被证明是用于大数据的流行框架,因此我们希望强调一个常见的用例和考虑因素。在这里,我们使用一个经典的数据科学挑战数据集,即纽约市黄色出租车,并介绍一个数据工程师处理此数据集可能考虑的内容。在涵盖机器学习工作负载的后续章节中,我们将使用许多 DataFrame 工具来构建。

决定使用 Dask

正如前面讨论的,Dask 在数据并行任务中表现出色。一个特别适合的数据集是可能已经以列格式,如 Parquet 格式,可用的数据集。我们还评估数据存储在哪里,例如在 S3 或其他远程存储选项中。许多数据科学家和工程师可能会有一个不能在单台机器上容纳或由于合规性约束而无法在本地存储的数据集。Dask 的设计非常适合这些用例。

我们的 NYC 出租车数据符合所有这些标准:数据以 Parquet 格式由纽约市存储在 S3 中,并且它可以轻松地进行横向和纵向扩展,因为它按日期进行了分区。此外,我们评估数据已经结构化,因此我们可以使用 Dask DataFrame。由于 Dask DataFrames 和 pandas DataFrames 相似,我们还可以使用许多现有的 pandas 工作流。我们可以对其中一些样本进行采样,在较小的开发环境中进行探索性数据分析,然后使用相同的代码扩展到完整数据集。请注意,在示例4-16 中,我们使用行组来指定分块行为。

示例 4-16. 使用 Dask DataFrame 加载多个 Parquet 文件
filename = './nyc_taxi/*.parquet'df_x = dd.read_parquet( filename, split_row_groups=2)

使用 Dask 进行探索性数据分析

数据科学的第一步通常包括探索性数据分析(EDA),或者了解数据集并绘制其形状。在这里,我们使用 Dask DataFrames 来走过这个过程,并检查由于 pandas DataFrame 和 Dask DataFrame 之间微妙差异而引起的常见故障排除问题。

加载数据

第一次将数据加载到您的开发环境中时,您可能会遇到块大小问题或模式问题。虽然 Dask 尝试推断两者,但有时会失败。块大小问题通常会在您对微不足道的代码调用.compute()时出现,看到一个工作线程达到内存限制。在这种情况下,需要进行一些手动工作来确定正确的块大小。模式问题将显示为读取数据时的错误或警告,或者稍后以微妙的方式显示,例如不匹配的 float32 和 float64。如果您已经了解模式,建议在读取时通过指定 dtype 来强制执行。

在进一步探索数据集时,您可能会遇到默认以您不喜欢的格式打印的数据,例如科学计数法。这可以通过 pandas 而不是 Dask 本身来控制。Dask 隐式调用 pandas,因此您希望使用 pandas 显式设置您喜欢的格式。

数据的汇总统计工作类似于 pandas 的.describe(),还可以指定百分位数或.quantile()。请注意,如果运行多个这样的计算,请链式调用它们,这样可以节省计算时间。在 示例4-17 中展示了如何使用 Dask DataFrame 的 describe 方法。

示例 4-17. 使用漂亮格式描述百分位数的 Dask DataFrame
import pandas as pdpd.set_option('display.float_format', lambda x: '%.5f' % x)df.describe(percentiles=[.25, .5, .75]).compute()

绘制数据

绘制数据通常是了解数据集的重要步骤。绘制大数据是一个棘手的问题。作为数据工程师,我们经常通过首先使用较小的采样数据集来解决这个问题。为此,Dask 可以与 Python 绘图库(如 matplotlib 或 seaborn)一起使用,就像 pandas 一样。Dask DataFrame 的优势在于,现在我们可以绘制整个数据集(如果需要的话)。我们可以使用绘图框架以及 Dask 来绘制整个数据集。在这里,Dask 进行筛选、分布式工作节点的聚合,然后收集到一个非分布式库(如 matplotlib)来渲染的工作节点。Dask DataFrame 的绘图示例显示在 示例4-18 中。

示例 4-18. Dask DataFrame 绘制行程距离
import matplotlib.pyplot as pltimport seaborn as sns import numpy as npget_ipython().run_line_magic('matplotlib', 'inline')sns.set(style="white", palette="muted", color_codes=True)f, axes = plt.subplots(1, 1, figsize=(11, 7), sharex=True)sns.despine(left=True)sns.distplot( np.log( df['trip_distance'].values + 1), axlabel='Log(trip_distance)', label='log(trip_distance)', bins=50, color="r")plt.setp(axes, yticks=[])plt.tight_layout()plt.show()
小贴士

请注意,如果你习惯于 NumPy 的逻辑,绘制时需要考虑到 Dask DataFrame 层。例如,NumPy 用户会熟悉 df[col].values 语法用于定义绘图变量。在 Dask 中,.values 执行的操作不同;我们传递的是 df[col]

检查数据

Pandas DataFrame 用户熟悉.loc().iloc()用于检查特定行或列的数据。这种逻辑转换到 Dask DataFrame,但是.iloc()的行为有重要的区别。

充分大的 Dask DataFrame 将包含多个 pandas DataFrame。这改变了我们应该如何思考编号和索引的方式。例如,对于 Dask,像.iloc()(通过索引访问位置的方法)不会完全像 pandas 那样工作,因为每个较小的 DataFrame 都有自己的.iloc()值,并且 Dask 不会跟踪每个较小 DataFrame 的大小。换句话说,全局索引值对于 Dask 来说很难确定,因为 Dask 将不得不逐个计算每个 DataFrame 才能获得索引。用户应该检查他们的 DataFrame 上的.iloc()并确保索引返回正确的值。

小贴士

请注意,调用像.reset_index()这样的方法可能会重置每个较小的 DataFrame 中的索引,当用户调用.iloc()时可能返回多个值。

在本章中,你已经了解到了如何理解 Dask 中哪些操作比你预期的更慢。你还学到了一些处理 pandas DataFrames 和 Dask DataFrames 性能差异的技术。通过理解 Dask DataFrames 性能可能不符合需求的情况,你也了解到了哪些问题不适合使用 Dask。为了能够综合这些内容,你还了解了 Dask DataFrame 的 IO 选项。从这里开始,你将继续学习有关 Dask 的其他集合,然后再进一步了解如何超越集合。

在本章中,你已经了解到可能导致 Dask DataFrames 行为与预期不同或更慢的原因。对于 Dask DataFrames 实现方式的相同理解可以帮助你确定分布式 DataFrames 是否适合你的问题。你还看到了如何将超过单台机器处理能力的数据集导入和导出 Dask 的 DataFrames。

¹ 参见“分区/分块集合”进行分区的复习。

² FSSPEC 文档包含配置每个后端的具体信息。

³ 这可能导致在执行聚合时出现内存不足异常。存储空间的线性增长要求(在一个常数因子内)所有数据都必须能够适应单个进程,这限制了 Dask 的有效性。

⁴ 准确分位数的备选算法依赖更多洗牌操作来减少空间开销。

⁵ 键偏斜可能会使已知分区器无法处理。

⁶ 严格递增且无重复值(例如,1、4、7 是单调递增的,但 1、4、4、7 不是)。

尴尬并行问题是指分布式计算和通信的开销很低的问题。

^ ⁸ 当不存在索引时,Dask 假定索引是对齐的。

到目前为止,您已经看到了 Dask 是如何构建的基础知识,以及 Dask 如何利用这些构建模块支持数据科学与数据帧。本章探讨了 Dask 的 bag 和 array 接口的地方——相对于数据帧而言,这些接口经常被忽视更合适。正如在“Hello Worlds”中提到的,Dask 袋实现了常见的函数式 API,而 Dask 数组实现了 NumPy 数组的一个子集。

提示

理解分区对于理解集合非常重要。如果您跳过了“分区/分块集合”,现在是回头看一看的好时机。

Dask 数组实现了 NumPy ndarray 接口的一个子集,使它们非常适合用于将使用 NumPy 的代码移植到 Dask 上运行。你从上一章对数据帧的理解大部分都适用于 Dask 数组,以及你对 ndarrays 的理解。

常见用例

Dask 数组的一些常见用例包括:

  • 大规模成像和天文数据

  • 天气数据

  • 多维数据

与 Dask 数据帧和 pandas 类似,如果在较小规模问题上不使用 nparray,那么 Dask 数组可能不是正确的解决方案。

不适用 Dask 数组的情况

如果你的数据适合在单台计算机的内存中,使用 Dask 数组不太可能比 nparrays 带来太多好处,特别是与像 Numba 这样的本地加速器相比,Numba 适用于使用和不使用图形处理单元(GPUs)的本地任务的向量化和并行化。你可以使用 Numba 与或不使用 Dask,并且我们将看看如何在第十章中进一步加速 Dask 数组使用 Numba。

Dask 数组与其本地对应物一样,要求数据都是相同类型的。这意味着它们不能用于半结构化或混合类型数据(例如字符串和整数)。

加载/保存

与 Dask 数据帧一样,加载和写入函数以 to_read_ 作为前缀开始。每种格式都有自己的配置,但一般来说,第一个位置参数是要读取数据的位置。位置可以是文件的通配符路径(例如 *s3://test-bucket/magic/**),文件列表或常规文件位置。

Dask 数组支持读取以下格式:

  • zarr

  • npy 堆栈(仅本地磁盘)

以及读取和写入:

  • hdf5

  • zarr

  • tiledb

  • npy 堆栈(仅本地磁盘)

另外,您可以将 Dask 数组转换为/从 Dask 袋和数据帧(如果类型兼容)。正如您可能已经注意到的那样,Dask 不支持从许多格式读取数组,这为使用袋提供了一个绝佳的机会(在下一节中介绍)。

有何缺失

虽然 Dask 数组实现了大量的 ndarray API,但并非完整集合。与 Dask 数据框一样,部分省略是有意的(例如,sort,大部分 linalg 等,这些操作会很慢),而其他部分则是因为还没有人有时间来实现它们。

特殊的 Dask 函数

与分布式数据框一样,Dask 数组的分区性质使得性能略有不同,因此有一些在 numpy.linalg 中找不到的独特 Dask 数组函数:

map_overlap

您可以将此用于数据的任何窗口视图,例如在 Dask 数据框上的 map_overlap

map_blocks

这类似于 Dask 的 DataFrames map_partitions,您可以用它来实现尚未在标准 Dask 库中实现的尴尬并行操作,包括 NumPy 中的新元素级函数。

topk

这将返回数组的前 k 个元素,而不是完全排序它(后者要显著更昂贵)。¹

compute_chunk_sizes

Dask 需要知道块的大小来支持索引;如果一个数组具有未知的块大小,您可以调用此函数。

这些特殊函数在基础常规集合上不存在,因为它们在非并行/非分布式环境中无法提供相同的性能节省。

继续与 Python 内部数据结构类比,您可以将 bags 视为稍有不同的列表或集合。 Bags 类似于列表,但没有顺序的概念(因此没有索引操作)。或者,如果您将 bags 视为集合,则它们与集合的区别在于 bags 允许重复。 Dask 的 bags 对它们包含的内容没有太多限制,并且同样具有最小的 API。 实际上,示例 2-6 到 2-9 涵盖了 bags 的核心 API 大部分内容。

提示

对于从 Apache Spark 转来的用户,Dask bags 与 Spark 的 RDDs 最为接近。

常见用例

当数据的结构未知或不一致时,bags 是一个很好的选择。 一些常见用例包括:

  • 将一堆 dask.delayed 调用分组在一起,例如,用于加载混乱或非结构化(或不支持的)数据。

  • “清理”(或为其添加结构)非结构化数据(如 JSON)。

  • 在固定范围内并行化一组任务,例如,如果您想调用 API 100 次,但不关心细节。

  • 总括来说:如果数据不适合任何其他集合类型,bags 是您的朋友。

我们认为 Dask bags 最常见的用例是加载混乱数据或 Dask 没有内置支持的数据。

加载和保存 Dask Bags

Dask Bag 内置了用于文本文件的读取器,使用 read_text,以及 Avro 文件的读取器,使用 read_avro。同样,您也可以将 Dask Bag 写入文本文件和 Avro 文件,尽管结果必须可序列化。当 Dask 的内置工具无法满足读取数据需求时,通常会使用 Bags,因此接下来的部分将深入讲解如何超越这两种内置格式。

使用 Dask Bag 加载混乱数据

通常,在加载混乱数据时的目标是将其转换为结构化格式以便进一步处理,或者至少提取您感兴趣的组件。虽然您的数据格式可能略有不同,但本节将介绍如何加载一些混乱的 JSON 数据,并提取一些相关字段。不用担心——我们会指出不同格式或来源可能需要不同技术的地方。

对于混乱的文本数据(在 JSON 中很常见),您可以通过使用 bags 的 read_text 函数节省一些时间。read_text 函数默认按行分割记录;然而,许多格式不能通过行来处理。为了获取每个完整文件作为一个整体记录而不是被分割开,您可以将 linedelimiter 参数设置为找不到的值。通常 REST API 会返回结果作为一个子组件,因此在 示例5-1 中,我们加载 美国食品药品管理局(FDA)召回数据集 并将其剥离到我们关心的部分。FDA 召回数据集是 JSON 数据中经常遇到的嵌套数据集的一个精彩现实世界示例,直接在 DataFrame 中处理这类数据集可能会很困难。

示例 5-1. 预处理 JSON
def make_url(idx): page_size = 100 start = idx * page_size u = f"https://api.fda.gov/food/enforcement.json?limit={page_size}&skip={start}" return uurls = list(map(make_url, range(0, 10)))# Since they are multi-line json we can't use the default \n line delimraw_json = bag.read_text(urls, linedelimiter="NODELIM")def clean_records(raw_records): import json # We don't need the meta field just the results field return json.loads(raw_records)["results"]cleaned_records = raw_json.map(clean_records).flatten()# And now we can convert it to a DataFramedf = bag.Bag.to_dataframe(cleaned_records)

如果您需要从不受支持的源(如自定义存储系统)或二进制格式(如协议缓冲区或灵活图像传输系统)加载数据,您需要使用较低级别的 API。对于仍存储在像 S3 这样的 FSSPEC 支持的文件系统中的二进制文件,您可以尝试 示例5-2 中的模式。

示例 5-2. 从 FSSPEC 支持的文件系统加载 PDF
def discover_files(path: str): (fs, fspath) = fsspec.core.url_to_fs(path) return (fs, fs.expand_path(fspath, recursive="true"))def load_file(fs, file): """Load (and initially process) the data.""" from PyPDF2 import PdfReader try: file_contents = fs.open(file) pdf = PdfReader(file_contents) return (file, pdf.pages[0].extract_text()) except Exception as e: return (file, e)def load_data(path: str): (fs, files) = discover_files(path) bag_filenames = bag.from_sequence(files) contents = bag_filenames.map(lambda f: load_file(fs, f)) return contents

如果您没有使用 FSSPEC 支持的文件系统,您仍然可以按照 示例5-3 中所示的方式加载数据。

示例 5-3. 使用纯定制函数加载数据
def special_load_function(x): ## Do your special loading logic in this function, like reading a database return ["Timbit", "Is", "Awesome"][0: x % 4]partitions = bag.from_sequence(range(20), npartitions=5)raw_data = partitions.map(special_load_function).flatten()
注意

以这种方式加载数据要求每个文件能够适合一个 worker/executor。如果不符合该条件,情况会变得更加复杂。实现可分割的数据读取器超出了本书的范围,但您可以查看 Dask 的内部 IO 库(文本是最简单的)以获取一些灵感。

有时候,对于嵌套的目录结构,创建文件列表可能需要很长时间。在这种情况下,将文件列表并行化是值得的。有许多不同的技术可以并行化文件列表,但为了简单起见,我们展示了在 示例5-4 中递归并行列出的方式。

示例 5-4. 并行列出文件(递归)
def parallel_recursive_list(path: str, fs=None) -> List[str]: print(f"Listing {path}") if fs is None: (fs, path) = fsspec.core.url_to_fs(path) info = [] infos = fs.ls(path, detail=True) # Above could throw PermissionError, but if we can't list the dir it's # probably wrong so let it bubble up files = [] dirs = [] for i in infos: if i["type"] == "directory": # You can speed this up by using futures; covered in Chapter 6 dir_list = dask.delayed(parallel_recursive_list)(i["name"], fs=fs) dirs += dir_list else: files.append(i["name"]) for sub_files in dask.compute(dirs): files.extend(sub_files) return files
提示

你并不总是需要自己进行目录列表。检查一下是否有元数据存储(例如 Hive 或 Iceberg)可能会有所帮助,它可以提供文件列表,而不需要进行所有这些慢速 API 调用。

这种方法有一些缺点:即所有的文件名都回到一个单一的点——但这很少是个问题。然而,如果你的文件列表甚至只是太大而无法在内存中容纳,你可能会尝试使用递归算法来进行目录发现,然后采用迭代算法来列出文件,保持文件名在袋子里。² 代码会变得稍微复杂一些,如示例 5-5 所示,所以这种最后的方法很少被使用。

示例 5-5. 并行列出文件而不收集到驱动程序
def parallel_list_directories_recursive(path: str, fs=None) -> List[str]: """ Recursively find all the sub-directories. """ if fs is None: (fs, path) = fsspec.core.url_to_fs(path) info = [] # Ideally, we could filter for directories here, but fsspec lacks that (for # now) infos = fs.ls(path, detail=True) # Above could throw PermissionError, but if we can't list the dir, it's # probably wrong, so let it bubble up dirs = [] result = [] for i in infos: if i["type"] == "directory": # You can speed this up by using futures; covered in Chapter 6 result.append(i["name"]) dir_list = dask.delayed( parallel_list_directories_recursive)(i["name"], fs=fs) dirs += dir_list for sub_dirs in dask.compute(dirs): result.extend(sub_dirs) return resultdef list_files(path: str, fs=None) -> List[str]: """List files at a given depth with no recursion.""" if fs is None: (fs, path) = fsspec.core.url_to_fs(path) info = [] # Ideally, we could filter for directories here, but fsspec lacks that (for # now) return map(lambda i: i["name"], filter( lambda i: i["type"] == "directory", fs.ls(path, detail=True)))def parallel_list_large(path: str, npartitions=None, fs=None) -> bag: """ Find all of the files (potentially too large to fit on the head node). """ directories = parallel_list_directories_recursive(path, fs=fs) dir_bag = dask.bag.from_sequence(directories, npartitions=npartitions) return dir_bag.map(lambda dir: list_files(dir, fs=fs)).flatten()

一个完全迭代的 FSSPEC 算法不会比天真的列表快,因为 FSSPEC 不支持仅查询目录。

限制

Dask bags 不太适合大多数的缩减或洗牌操作,因为它们的核心 reduction 函数将结果缩减到一个分区,需要所有的数据都能适应单台机器。你可以合理地使用纯粹的常量空间的聚合,例如平均值、最小值和最大值。然而,大多数情况下,你会发现自己尝试对数据进行聚合,你应该考虑将你的 bag 转换为 DataFrame,使用 bag.Bag.to_dataframe

提示

所有三种 Dask 数据类型(bag、array 和 DataFrame)都有被转换为其他数据类型的方法。然而,有些转换需要特别注意。例如,当将 Dask DataFrame 转换为 Dask array 时,生成的数组将具有 NaN,如果你查看它生成的形状。这是因为 Dask DataFrame 不会跟踪每个 DataFrame 块中的行数。

虽然 Dask DataFrames 得到了最多的使用,但 Dask arrays 和 bags 也有它们的用处。你可以使用 Dask arrays 来加速和并行化大型多维数组处理。Dask bags 允许你处理不太适合 DataFrame 的数据,比如 PDF 或多维嵌套数据。这些集合比 Dask DataFrames 得到的关注和积极的开发要少得多,但可能仍然在你的工作流程中有它们的位置。在下一章中,你将看到如何向你的 Dask 程序中添加状态,包括对 Dask 集合的操作。

¹ topk 提取每个分区的前 k 个元素,然后只需要将 k 个元素从每个分区洗牌出来。

² 迭代算法涉及使用 whilefor 这样的结构,而不是对同一函数的递归调用。

Dask 的计算流程遵循这四个主要逻辑步骤,每个任务可以并发和递归地进行:

  1. 收集并读取输入数据。

  2. 定义并构建表示需要对数据执行的计算集的计算图。

  3. 运行计算(这在运行.compute()时发生)。

  4. 将结果作为数据传递给下一步。

现在我们介绍更多使用 futures 控制这一流程的方法。到目前为止,您大部分时间在 Dask 中看到的是惰性操作,Dask 不会做任何工作,直到有事情强制执行计算。这种模式有许多好处,包括允许 Dask 的优化器在合适时合并步骤。然而,并非所有任务都适合惰性评估。一个常见的不适合惰性评估的模式是“fire-and-forget”,我们为其副作用而调用函数¹并且需要关注输出。尝试使用惰性评估(例如dask.delayed)来表达这一点会导致不必要的阻塞以强制执行计算。当惰性评估不是您需要的时候,您可以探索 Dask 的 futures。 Futures 可以用于远比 fire-and-forget 更多的用例。本章将探讨 futures 的许多常见用例。

注意

您可能已经熟悉 Python 中的 futures。Dask 的 futures 是 Python concurrent.futures 库的扩展,允许您在其位置使用它们。类似于使用 Dask DataFrames 替代 pandas DataFrames,行为可能略有不同(尽管这里的差异较小)。

Dask futures 是 Dask 的分布式客户端库的一部分,因此您将通过from dask.distributed import Client导入它来开始。

提示

尽管名称如此,您可以在本地使用 Dask 的分布式客户端。有关不同的本地部署类型,请参阅“分布式(Dask 客户端和调度器)”。

热切评估是编程中最常见的评估形式,包括 Python。虽然大多数热切评估是阻塞的——也就是说,程序在结果完成之前不会移到下一个语句——但您仍然可以进行异步/非阻塞的热切评估。 Futures 是表示非阻塞热切计算的一种方式。

非阻塞热切评估与懒惰评估相比仍然存在一些潜在缺点。其中一些挑战包括:

  • 无法合并相邻阶段(有时被称为流水线)

  • 不必要的计算:

    • Dask 的优化器无法检测重复的子图。

    • 即使未依赖于未来结果的任何内容,它也可以计算。²

  • 当未来启动并在其他未来上阻塞时,可能会出现过多的阻塞

  • 需要更谨慎的内存管理

并非所有 Python 代码都会立即评估。在 Python 3 中,一些内置函数使用惰性评估,像 map 返回迭代器并且仅在请求时评估元素。

许多常见用例可以通过仔细应用 futures 加速:

与其他异步服务器(如 Tornado)集成

尽管我们通常认为 Dask 大多数情况下不是“热路径”的正确解决方案,但也有例外情况,如动态计算的分析仪表板。

请求/响应模式

调用远程服务并(稍后)阻塞其结果。这可能包括查询诸如数据库、远程过程调用甚至网站等服务。

IO

输入/输出通常很慢,但你确实希望它们尽快开始。

超时

有时候你只在特定时间内获取结果感兴趣。例如,考虑一个增强的 ML 模型,你需要在一定时间内做出决策,迅速收集所有可用模型的分数,然后跳过超时的模型。

点火并忘记

有时候你可能不关心函数调用的结果,但你确实希望它被调用。Futures 允许你确保计算发生,而无需阻塞等待结果。

Actors

调用 actors 的结果是 futures。我们将在下一章介绍 actors。

在 Dask 中启动 futures 是非阻塞的,而在 Dask 中计算任务是阻塞的。这意味着当你向 Dask 提交一个 future 时,它会立即开始工作,但不会阻止(或阻塞)程序继续运行。

启动 Dask futures 的语法与 dask.delayed 稍有不同。Dask futures 是通过 Dask 分布式客户端使用 submit 单个 future 或 map 多个 futures 启动,如 Example 6-1 所示。

Example 6-1. 启动 futures
from dask.distributed import Clientclient = Client()def slow(x): time.sleep(3 * x) return 3 * xslow_future = client.submit(slow, 1)slow_futures = client.map(slow, range(1, 5))

dask.delayed 不同的是,一旦启动了 future,Dask 就开始计算其值。

注意

虽然这里的 map 与 Dask bags 上的 map 有些相似,但每个项都会生成一个单独的任务,而 bags 能够将任务分组到分区以减少开销(尽管它们是延迟评估的)。

在 Dask 中,像 persist() 在 Dask 集合上一样,使用 futures 在内部。通过调用 futures_of 可以获取已持久化集合的 futures。这些 futures 的生命周期与您自己启动的 futures 相同。

期货与dask.delayed有着不同的生命周期,超出了急切计算。使用dask.delayed时,中间计算会自动清理;然而,Dask 期货的结果会一直保存,直到期货显式取消或其引用在 Python 中被垃圾回收。如果你不再需要期货的值,你可以取消它并释放任何存储空间或核心,方法是调用.cancel。期货的生命周期在示例6-2 中有所说明。

示例 6-2. 期货生命周期
myfuture = client.submit(slow, 5) # Starts runningmyfuture = None # future may be GCd and then stop since there are no other referencesmyfuture = client.submit(slow, 5) # Starts runningdel myfuture # future may be GCd and then stop since there are no other referencesmyfuture = client.submit(slow, 5) # Starts running# Future stops running, any other references point to canceled futuremyfuture.cancel()

取消期货的行为与删除或依赖垃圾回收不同。如果有其他引用指向期货,则删除或将单个引用设置为None将不会取消期货。这意味着结果将继续存储在 Dask 中。另一方面,取消期货的缺点是,如果你错误地需要期货的值,这将导致错误。

警告

在 Jupyter 笔记本中使用 Dask 时,笔记本可能会“保留”任何先前单元格的结果,因此即使期货未命名,它也将保留在 Dask 中。有一个关于此的讨论,对于有兴趣的人来说,可以了解更多背景。

期货的字符串表示将向你展示它在生命周期中的位置(例如,Future: slow status: cancelled,)。

有时你不再需要一个期货,但你也不希望它被取消。这种模式称为火而忘之。这在像写数据、更新数据库或其他副作用的情况下最有用。如果所有对期货的引用都丢失了,垃圾回收可能导致期货被取消。为了解决这个问题,Dask 有一个名为fire_and_forget的方法,可以让你利用这种模式,就像在示例6-3 中展示的那样,而不需要保留引用。

示例 6-3. 火而忘之
from dask.distributed import fire_and_forgetdef do_some_io(data): """ Do some io we don't need to block on :) """ import requests return requests.get('https://httpbin.org/get', params=data)def business_logic(): # Make a future, but we don't really care about its result, just that it # happens future = client.submit(do_some_io, {"timbit": "awesome"}) fire_and_forget(future)business_logic()

更常见的是,你最终会想知道期货计算了什么(甚至只是是否遇到了错误)。对于不仅仅是副作用的期货,你最终会想要从期货获取返回值(或错误)。期货有阻塞方法result,如示例6-4 所示,它会将期货中计算的值返回给你,或者从期货中引发异常。

示例 6-4. 获取结果
future = client.submit(do_some_io, {"timbit": "awesome"})future.result()

你可以扩展到多个期货,如示例6-5,但有更快的方法可以做到。

示例 6-5. 获取结果列表
for f in futures: time.sleep(2) # Business numbers logic print(f.result())

如果你同时拥有多个期货(例如通过map创建),你可以在它们逐步可用时获取结果(参见示例6-6)。如果可以无序处理结果,这可以极大地提高处理时间。

示例 6-6. 当结果逐步可用时获取结果列表
from dask.distributed import as_completedfor f in as_completed(futures): time.sleep(2) # Business numbers logic print(f.result())

在上面的例子中,通过处理完成的 futures,你可以让主线程在每个元素变得可用时执行其“业务逻辑”(类似于聚合的 combine 步骤)。如果 futures 在不同的时间完成,这可能会大大提高速度。

如果你有一个截止期限,比如为广告服务评分³ 或者与股票市场进行一些奇特的操作,你可能不想等待所有的 futures。相反,wait 函数允许你在超时后获取结果,如 示例6-7 所示。

示例 6-7. 获取第一个 future(在时间限制内)
from dask.distributed import waitfrom dask.distributed.client import FIRST_COMPLETED# Will throw an exception if no future completes in time.# If it does not throw, the result has two lists:# The done list may return between one and all futures.# The not_done list may contain zero or more futures.finished = wait(futures, 1, return_when=FIRST_COMPLETED)# Process the returned futuresfor f in finished.done: print(f.result())# Cancel the futures we don't needfor f in finished.not_done: f.cancel()

这个时间限制可以应用于整个集合,也可以应用于一个 future。如果你想要所有的特性在给定时间内完成,那么你需要做更多的工作,如 示例6-8 所示。

示例 6-8. 获取在时间限制内完成的任何 futures
max_wait = 10start = time.time()while len(futures) > 0 and time.time() - start < max_wait: try: finished = wait(futures, 1, return_when=FIRST_COMPLETED) for f in finished.done: print(f.result()) futures = finished.not_done except TimeoutError: True # No future finished in this cycle# Cancel any remaining futuresfor f in futures: f.cancel()

现在你可以从 futures 中获取结果了,你可以比较 dask.delayed 与 Dask futures 的执行时间,如 示例6-9 所示。

示例 6-9. 查看 futures 可能更快
slow_future = client.submit(slow, 1)slow_delayed = dask.delayed(slow)(1)# Pretend we do some other work heretime.sleep(1)future_time = timeit.timeit(lambda: slow_future.result(), number=1)delayed_time = timeit.timeit(lambda: dask.compute(slow_delayed), number=1)print( f"""So as you can see by the future time {future_time} v.s. {delayed_time} the future starts running right away.""")

在这个(虽然有些牵强的)例子中,你可以看到,通过尽早开始工作,future 在你得到结果时已经完成了,而 dask.delayed 则是在你到达时才开始的。

dask.delayed 一样,你也可以从内部启动 futures。语法略有不同,因为你需要获取 client 对象的实例,它不可序列化,所以 dask.distributed 有一个特殊函数 get_client 来在分布式函数中获取 client。一旦你有了 client,你就可以像平常一样启动 future,如 示例6-10 所示。

示例 6-10. 启动一个嵌套 future
from dask.distributed import get_clientdef nested(x): client = get_client() # The client is serializable, so we use get_client futures = client.map(slow, range(0, x)) r = 0 for f in as_completed(futures): r = r + f.result() return rf = client.submit(nested, 3)f.result()

注意,由于 Dask 使用集中式调度程序,客户端正在与该集中式调度程序通信,以确定在哪里放置 future。

虽然 Dask 的主要构建块是 dask.delayed,但它并不是唯一的选择。你可以通过使用 Dask 的 futures 来控制更多的执行流程。Futures 非常适合 I/O、模型推断和对截止日期敏感的应用程序。作为对这种额外控制的交换,你需要负责管理 futures 的生命周期以及它们产生的数据,这是使用 dask.delayed 时不需要的。Dask 还有许多分布式数据结构,包括队列、变量和锁。虽然这些分布式数据结构比它们的本地对应物更昂贵,但它们也为你在控制任务调度方面提供了另一层灵活性。

¹ 就像写入磁盘文件或更新数据库记录一样。

² 不过,如果它的唯一引用被垃圾回收了,可能就不行了。

³ 我们认为这是 Dask 在发展空间较大的领域之一,如果你想要为截止日期关键事件实现微服务,可能需要考虑将 Dask 与 Ray 等其他系统结合使用。

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 是 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。在 Example7-1 中,我们定义了三个方法——balancedepositwithdrawal——用于与 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 workeraccount_future = client.submit(BankAccount, actor=True)account = account_future.result()

当您在生成的代理对象上调用方法时(参见示例7-2),Dask 将调度远程过程调用并立即返回一个特殊的 ActorFuture。这使您可以以非阻塞方式使用 actors。与通用的@dask.delayed调用不同,这些调用都被路由到同一个进程,即 Dask 安排 actor 的进程。

示例 7-2. 使用银行账户 actor
# Non-blockingbalance_future = account.balance()# Blocksbalance = balance_future.result()try: f = account.withdrawal(100) f.result() # throws an exceptionexcept 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 共享数据库的情况下,可以使用轮询或其他非确定性的负载均衡。

Python-Dask-扩展指南-早期发布--全- - 绝不原创的飞龙 - 博客园 (4)

图 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

行业中的一个常见问题是没有意识到我们很酷的新工具并不一定适合当前的工作。正如俗话说,“拿着锤子,眼前的都是钉子。” 如果你不需要改变状态,应该坚持使用任务而不是 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 社区正在围绕它们不断壮大。这使我们对这些库中的许多将长期存在感到有信心。表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 被许多人使用。

作为软件开发人员和数据科学家,我们经常试图使用定量指标来做出决策。软件的定量指标,无论是开源还是闭源,都是一个活跃研究领域,因此我们无法覆盖所有的定量指标。所有开源项目的一个重大挑战是,特别是一旦涉及到资金,这些指标可能会受到影响。我们建议集中精力关注定性因素,虽然这些因素更难以衡量,但也更难以被操纵。

这里我们涵盖了一些人们常常试图使用的常见指标,还有许多其他评估开源项目可用性的框架,包括OSSMOpenSSF 安全度量,和更多。其中一些框架表面上产生了自动化评分(如 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。

Python-Dask-扩展指南-早期发布--全- - 绝不原创的飞龙 - 博客园 (5)

图 8-1. Dask Kubernetes 安装统计数据来自 PyPI 统计

寻找人们使用某个库的例子有其局限性,特别是在数据处理方面。由于数据和机器学习管道并不经常开源,因此对于用于这些目的的库,找到例子可能更加困难。

另一个可以参考使用情况的指标是问题或邮件列表帖子的频率。如果项目托管在类似 GitHub 的平台上,星星数量也可以作为衡量使用情况的一种有趣方式——但由于人们现在可以像购买 Instagram 点赞一样购买 GitHub 星星(如图 8-2 所示),因此不应过分依赖此指标。¹⁰

即使不考虑购买星星的情况,什么样的项目值得加星也因人而异。一些项目会请求许多人加星,虽然没有购买星星,但这可能会快速增加此指标。¹¹

Python-Dask-扩展指南-早期发布--全- - 绝不原创的飞龙 - 博客园 (6)

图 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 的主要迁移路径。

以下是考虑从现有在 pandas 中实现的作业或 PySpark 等分布式库迁移到 Dask 的一些理由:

Python 和 PyData 堆栈

许多数据科学家和开发人员更喜欢使用 Python 本地堆栈,他们不需要在不同语言或风格之间切换。

与 Dask API 更丰富的 ML 集成

Futures、delayed 和 ML 集成要求开发人员减少粘合代码的编写,由于 Dask 提供更灵活的任务图管理,性能有所提升。

精细化任务管理

Dask 的任务图在运行时实时生成和维护,并且用户可以同步访问任务字典。

调试开销

一些开发团队更喜欢 Python 中的调试体验,而不是混合 Python 和 Java/Scala 堆栈跟踪。

开发开销

在 Dask 中进行开发步骤可以轻松在开发者的笔记本电脑上完成,而不需要连接到强大的云机器以进行实验。

管理用户体验

Dask 的可视化工具往往更具视觉吸引力和直观性,具有用于任务图的本地 graphviz 渲染。

这些并非所有的优势,但如果其中任何一个对你有说服力,考虑将工作负载转移到 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 YarnClusterfrom dask.distributed import Client# Create a cluster where each worker has two cores and 8 GiB of memorycluster = YarnCluster( environment='your_environment.tar.gz', worker_vcores=2, worker_memory="4GiB")# Scale out to num_workers such workerscluster.scale(num_workers)# Connect to the clusterclient = Client(cluster)

如果您的组织有多个受支持的集群,选择一个可以自助依赖管理的集群,如 Kubernetes,将是有益的。

对于使用 PBS、Slurm、MOAB、SGE、LSF 和 HTCondor 等作业队列系统进行高性能计算部署,应使用 Dask-jobqueue,如 示例9-2 所示。

示例 9-2. 使用 jobqueue 在 Slurm 上部署 Dask
from dask_jobqueue import SLURMClusterfrom dask.distributed import Clientcluster = SLURMCluster( queue='regular', account="slurm_caccount", cores=24, memory="500 GB")cluster.scale(jobs=SLURM_JOB_COUNT) # Ask for N jobs from Slurmclient = Client(cluster)# Auto-scale between 10 and 100 jobscluster.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 s3fsimport pyarrow as paimport pyarrow.parquet as pqminio_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,其索引有两列 col1col2)来时的一个简单解决方法是在 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 setupsetup (is_lab=True)ur = ('https://d37ci6vzurychx.cloudfront.net/trip-data/' 'yellow_tripdata_2018-01.parquet')df = dd.read_parquet(url)%%fsql dasktempdf = SELECT VendorID, AVG (total_amount) AS average_fare FROM dfGROUP BY VendorIDSELECT *FROM tempdfORDER BY average fare DESCLIMIT 5PRINT
VendorIDaverage_fare
0115.127384
1215.775723
schema: VendorID:long, average_fare:double

另一种方法是使用 Dask-SQL 库。该软件包使用 Apache Calcite 提供 SQL 解析前端,并用于查询 Dask 数据帧。使用该库,你可以将大多数基于 SQL 的操作传递给 Dask-SQL 上下文,并进行处理。引擎处理标准 SQL 输入,如 SELECTCREATE TABLE,同时还支持使用 CREATE MODEL 语法进行 ML 模型创建。

部署监控

像许多其他分布式库一样,Dask 提供日志记录功能,你可以配置 Dask 日志将其发送到存储系统。部署环境会影响方法的选择,以及是否涉及 Jupyter。

Dask 客户端暴露了 get_worker_logs()get_scheduler_logs() 方法,如果需要可以在运行时访问。此外,类似于其他分布式系统的日志记录,你可以按主题记录事件,使其易于按事件类型访问。

示例9-6 是在客户端添加自定义日志事件的玩具示例。

示例 9-6. 按主题进行基本日志记录
from dask.distributed import Clientclient = 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, LocalClusterclient = Client(cluster) # Connect to distributed cluster and override defaultd = {'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 retscores_df.apply(compute_softmax, axis=1, meta=object).compute()client.get_events("softmax")

在本章中,您已经审查了迁移现有分析工程工作的重要问题和考虑因素。您还了解了 Dask 与 Spark、R 和 pandas 之间的一些特征差异。一些特性尚未由 Dask 实现,一些特性则由 Dask 更为稳健地实现,还有一些是在将计算从单机迁移到分布式集群时固有的翻译差异。由于大规模数据工程倾向于在许多库中使用类似的术语和名称,往往容易忽视导致更大性能或正确性问题的细微差异。记住它们将有助于您在 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 加速。 GPUs 特别擅长在同一时间对大量数据点执行相同的计算。如果一个问题非常适合矢量化计算,那么 GPU 可能非常适合。

一些通常受益于 GPU 加速的常见问题包括:

  • 机器学习

  • 线性代数

  • 物理模拟

  • 图形(毫不意外)

GPUs 不太适合分支繁多且非矢量化的工作流程,或者数据复制成本与计算成本相似或更高的工作流程。

如果您已经确定您的问题非常适合专用资源,下一步是让调度器意识到哪些机器和进程拥有该资源。您可以通过添加环境变量或命令行标志到工作进程启动(例如 --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"] = 1worker_template.spec.containers[0].resources.requests["gpu"] = 1worker_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 LocalCUDAClusterfrom 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/daskFROM holdenk/dask:latest# arm64 channelRUN conda config --add channels rpi# Numba and conda-forge channelsRUN conda config --add channels numbaRUN conda config --add channels conda-forge# Some CUDA-specific stuffRUN conda config --add channels rapidsai# Accelerator libraries often involve a lot of native code, so it's# faster to install with condaRUN 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 pipRUN conda install cudf -y

然后,为了构建这个,我们运行示例10-4 中显示的脚本。

示例 10-4. 构建自定义 Dask Docker 容器
#/bin/bashset -exdocker buildx build -t holdenk/dask-extended --platform \ linux/arm64,linux/amd64 --push . -f Dockerfiledocker buildx build -t holdenk/dask-extended-notebook --platform \ linux/arm64,linux/amd64 --push . -f NotebookDockerfile

您必须确保需要加速器的任务在具有加速器的工作进程上运行。您可以在使用 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 / countarr = 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 / countarr = 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 的三个主要 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 回退是指尝试使用加速器,如 GPU 或 TPU,如果加速器不可用,则回退到常规 CPU 代码路径。在大多数情况下,这是一个好的设计模式,因为加速器(如 GPU)可能很昂贵,并且可能不总是可用。然而,在某些情况下,CPU 和 GPU 性能之间的差异是如此之大,以至于回退到 CPU 很难在实际时间内成功;这在深度学习算法中最常发生。

面向对象编程和鸭子类型在这种设计模式下有些适合,因为只要两个类实现了您正在使用的相同接口的部分,您就可以交换它们。然而,就像在 Dask DataFrames 中替换 pandas DataFrames 一样,它并不完美,特别是在性能方面。

警告

在一个更好的世界里,我们可以提交一个请求 GPU 资源的任务,如果没有被调度,我们可以切换回 CPU-only 资源。不幸的是,Dask 的资源调度更接近于“尽力而为”,³ 因此我们可能被调度到没有我们请求的资源的节点上。

专用加速器,如 GPU,可能会对您的工作流程产生很大影响。选择适合您工作流程的正确加速器很重要,有些工作流程不太适合加速。Dask 不会自动使用任何加速器,但有各种库可用于 GPU 计算。许多这些库创建时并没有考虑到共享计算的概念,因此要特别注意意外资源泄漏,特别是由于 GPU 资源往往更昂贵。

¹ 假设莎士比亚仍然活着,但事实并非如此。

² BlazingSQL 可能已接近其生命周期的尽头;已经有相当长一段时间没有提交更新,而且其网站看起来就像那些上世纪 90 年代的 GeoCities 网站一样简陋。

³ 这并没有像文档中描述的那样详细,因此可能在未来发生变化。

现在你已经了解了 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 在具有有限分布式可变状态(如大型模型权重)的并行任务中表现突出。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 是 Dask 的官方支持的机器学习库。在这里,我们将介绍 Dask-ML API 提供的功能,以及它如何将 Dask、pandas 和 scikit-learn 集成到其功能中,以及 Dask 和其 scikit-learn 等效物之间的一些区别。此外,我们还将详细讲解几个 XGBoost 梯度提升集成案例。我们将主要使用之前用过的纽约市黄色出租车数据进行示例演示。您可以直接从纽约市网站访问数据集。

特征工程

就像任何优秀的数据科学工作流一样,我们从清理、应用缩放器和转换开始。Dask-ML 提供了大部分来自 scikit-learn 的预处理 API 的即插即用替代品,包括 StandardScalerPolynomialFeaturesMinMaxScaler 等。

您可以将多个列传递给转换器,每个列都将被归一化,最终生成一个延迟的 Dask DataFrame,您应该调用 compute 方法来执行计算。

在 示例11-1 中,我们对行程距离(单位为英里)和总金额(单位为美元)进行了缩放,生成它们各自的缩放变量。这是我们在第四章中进行的探索性数据分析的延续。

示例 11-1. 使用 StandardScaler 对 Dask DataFrame 进行预处理
from dask_ml.preprocessing import StandardScalerimport dask.array as daimport numpy as npdf = 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 Categorizerfrom pandas.api.types import CategoricalDtypepayment_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.dtypespayment_type_amt_df.head()

或者,您可以选择使用 Dask DataFrame 的内置分类器。虽然 pandas 对 Object 和 String 作为分类数据类型是宽容的,但除非首先将这些列读取为分类变量,否则 Dask 将拒绝这些列。有两种方法可以做到这一点:在读取数据时将列声明为分类,使用dtype={col: categorical},或在调用get_dummies之前转换,使用df​.catego⁠rize(“col1”)。这里的理由是 Dask 是惰性评估的,不能在没有看到完整唯一值列表的情况下创建列的虚拟变量。调用.categorize()很方便,并允许动态处理额外的类别,但请记住,它确实需要先扫描整个列以获取类别,然后再扫描转换列。因此,如果您已经知道类别并且它们不会改变,您应该直接调用DummyEncoder

Example11-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 函数可以给您类似的结果。Example11-4 在分类列上演示了这一点,而 Example11-5 则预处理了日期时间。日期时间可能会带来一些棘手的问题。在这种情况下,Python 原生反序列化日期时间。请记住,始终在转换日期时间之前检查并应用必要的转换。

Example 11-4. 使用 Dask DataFrame 内置的虚拟变量作为哑变量的预处理
from dask_ml.preprocessing import DummyEncoderdummy = DummyEncoder()dummified_df = dummy.fit_transform(categorized_df)dummified_df.dtypesdummified_df.head()
Example 11-5. 使用 Dask DataFrame 内置的虚拟变量作为日期时间的预处理
train['Hour'] = train['tpep_pickup_datetime'].dt.hourtest['Hour'] = test['tpep_pickup_datetime'].dt.hourtrain['dayofweek'] = train['tpep_pickup_datetime'].dt.dayofweektest['dayofweek'] = test['tpep_pickup_datetime'].dt.dayofweektrain = 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 的实现则会在每个分区上分割训练集和测试集,避免洗牌(参见 Example11-6)。

Example 11-6. Dask DataFrame 伪随机分割
from dask_ml.model_selection import train_test_splitX_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 LinearRegressionfrom dask_ml.model_selection import train_test_splitregr_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 的便利包装器。

Example11-8 使用 scikit-learn 的学习函数 SGDRegressorLinearRegression,并使用 dask.delayed 将延迟功能包装在该方法周围。您可以对您想要并行化的任何代码片段执行此操作。

Example 11-8. 使用 Dask-ML 进行线性回归
from sklearn.linear_model import LinearRegression as ScikitLinearRegressionfrom sklearn.linear_model import SGDRegressor as ScikitSGDRegressorestimators = [ScikitLinearRegression(), ScikitSGDRegressor()]run_tasks = [dask.delayed(estimator.fit)(X_train, y_train) for estimator in estimators]run_tasks

使用 Dask 的 joblib。

或者,您可以使用 scikit-learn 与 joblib(参见 Example11-9),这是一个可以将任何 Python 函数作为流水线步骤在单台机器上计算的包。Joblib 在许多不依赖于彼此的并行计算中表现良好。在这种情况下,拥有单台机器上的数百个核心将非常有帮助。虽然典型的笔记本电脑没有数百个核心,但使用它所拥有的四个核心仍然是有益的。使用 Dask 版本的 joblib,您可以使用来自多台机器的核心。这对于在单台机器上计算受限的 ML 工作负载是有效的。

Example 11-9. 使用 joblib 进行计算并行化
from dask.distributed import Clientfrom joblib import parallel_backendclient = 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 xgbfrom dask_cuda import LocalCUDAClusterfrom dask.distributed import Clientn_workers = 4cluster = 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 xgbdtrain = 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 xgbfrom dask_cuda import LocalCUDAClusterfrom dask.distributed import Clientn_workers = 4cluster = 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 boosterdef predict(client, model, X): predictions = xgb.predict(client, model, X) assert isinstance(predictions, dd.Series) return predictions

另一个较新的添加是 Dask-SQL 库,它提供了一个便捷的包装器,用于简化 ML 模型训练工作负载。 示例11-13 加载与之前相同的 NYC 黄色出租车数据作为 Dask DataFrame,然后将视图注册到 Dask-SQL 上下文中。

示例 11-13. 将数据集注册到 Dask-SQL 中
import dask.dataframe as ddimport dask.datasetsfrom dask_sql import Context# read datasettaxi_df = dd.read_csv('./data/taxi_train_subset.csv')taxi_test = dd.read_csv('./data/taxi_test.csv')# create a context to register tablesc = 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 ddimport dask.datasetsfrom dask_sql import Contextc = Context()# define modelc.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 modelc.sql( """DESCRIBE MODEL fare_linreg_model """).compute()# run inferencec.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 ddimport dask.datasetsfrom dask_sql import Contextc = Context()# define modelc.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 modelc.sql( """DESCRIBE MODEL classify_faretype """).compute()# run inferencec.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 imreadfrom skimage.io.collection import alphanumeric_keyfrom dask import delayedimport dask.array as daimport osroot, dirs, filenames = os.walk(dataset_dir)# sample first fileimread(filenames[0])@dask.delayeddef lazy_reader(file): return imread(file)# we have a bunch of delayed readers attached to the fileslazy_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 largedask_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 ddimport dask.bag as dbdef rowwise_operation(row, arg *): # row-wise compute return resultdef partition_operation(df): # partition wise logic result = df[col1].apply(rowwise_operation) return resultddf = 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 = 1ddf.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 resultddf = 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,从在其他分布式计算引擎(如 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:² KubeCluster 和 HelmCluster。Helm 是管理 Kubernetes 上部署的流行工具,部署在 Helm 图表中指定。由于 Helm 是管理 Kubernetes 上部署的新推荐方式,我们将在这里涵盖这一点。

Helm 文档 提供了关于不同安装 Helm 方式的优秀起始点,但是对于那些着急的人,curl https://raw.githubuser​con⁠tent.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 如 Example12-1 所示。

Example 12-1. 使用 Helm 部署 Dask operator
# Add the repohelm repo add dask https://helm.dask.orghelm repo update# Install the operator; you will use this to create clustershelm install --create-namespace -n \ dask-operator --generate-name dask/dask-kubernetes-operator

现在你可以通过创建 YAML 文件(可能不是你最喜欢的方式)或者使用KubeCluster API 来使用 Dask operator,如 Example12-2 所示,在这里我们创建一个集群,然后添加额外的 worker 类型,允许 Dask 创建两种不同类型的 workers。⁴

Example 12-2. 使用 Dask operator
from dask_kubernetes.operator import KubeClustercluster = 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 neededcluster.scale("gpu", 5, worker_group="gpu")# Fancy machine learning logiccluster.scale("gpu", , worker_group="gpu")# Or just auto-scalecluster.adapt(minimum=1, maximum=10)

2020 年,Dask 添加了一个DaskHub Helm 图表,它将 JupyterHub 的部署与 Dask Gateway 结合在一起。

将 Dask 部署到 Ray 上与所有其他选项略有不同,因为它不仅改变了 Dask 工作节点和任务的调度方式,还改变了Dask 对象的存储方式。这可以减少需要存储的同一对象的副本数量,从而更有效地利用集群内存。

如果您已经有一个可用的 Ray 部署,启用 Dask 可能会非常简单,就像在示例12-3 中所示的那样。

示例 12-3. 在 Ray 上运行 Dask
import daskenable_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 是来自大数据领域的流行集群管理器,它在开源和商业的本地(例如 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 YarnClusterfrom dask.distributed import Clientimport loggingimport osimport sysimport timelogger = 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 executablecluster = 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 nodesversions = 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 itcluster = YarnCluster.from_current()# This would give you YARN application ID# application_1516806604516_0019# status check, kill, view log of applicationget_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 已经获得了大量的学术和科学用户群体。这在一定程度上归功于使用现有的高性能计算(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 环境。确切的操作方法会因集群管理员的偏好而异。一般来说,用户通常使用 virtualenvminiconda 在用户级别安装相关库。Min⁠i­conda 不仅可以更轻松地使用您自己的库,还可以使用您自己的 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 SLURMClusterfrom dask.distributed import Clientdef 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 clustercluster = 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 timefrom dask import delayedfrom dask.distributed import Client, LocalCluster# Note we introduce progress bar for future execution in a distributed# context herefrom dask.distributed import progressfrom dask_jobqueue import SLURMClusterimport numpy as npimport logginglogger = 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."@delayeddef 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_outputdef 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 SLURMClusterfrom dask import delayedfrom 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 jobcluster = 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 笔记本,否则可能会导致安全问题。

您可以像运行其他库一样在 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 所示。

Python-Dask-扩展指南-早期发布--全- - 绝不原创的飞龙 - 博客园 (7)

图 12-1. 在 JupyterLab 上成功部署的 Dask 实例(数字,彩色版本)

启动集群

从那里,您可以启动您的集群。默认情况下,该扩展程序启动一个本地集群,但您可以通过编辑~/.config/dask来配置它以使用不同的部署选项,包括 Kubernetes。

用户界面

如果您正在使用 Dask 的 JupyterLab 扩展(参见图12-2),它提供了一个到集群 UI 的链接,以及将单个组件拖放到 Jupyter 界面中的功能。

Python-Dask-扩展指南-早期发布--全- - 绝不原创的飞龙 - 博客园 (8)

图 12-2. 在 JupyterHub 内使用 JupyterLab 扩展显示的 Dask Web UI(数字,彩色版本

JupyterLab 扩展程序链接到 Dask Web UI,您还可以通过集群的repr获取链接。如果集群链接不起作用/无法访问,您可以尝试安装jupyter-server-proxy扩展程序,以便将笔记本主机用作跳转主机

观察进度

Dask 作业通常需要很长时间才能运行;否则我们不会努力并行化它们。您可以使用 Dask 的dask.distributed中的progress函数来跟踪您笔记本中的未来进度(参见图12-3)。

Python-Dask-扩展指南-早期发布--全- - 绝不原创的飞龙 - 博客园 (9)

图 12-3. 在 JupyterHub 中实时监控 Dask 进度(数字,彩色版本

调整您的 Dask 程序涉及理解多个组件的交集。您需要了解您的代码行为以及其与给定数据和机器的交互方式。您可以使用 Dask 指标来深入了解其中的许多内容,但特别是如果不是您创建的代码,查看程序本身也很重要。

分布式计算中的指标

分布式计算需要不断做出决策,并权衡分发工作负载的优化成本和收益。大部分低级别的决策都委托给 Dask 的内部。用户仍应监控运行时特性,并根据需要修改代码和配置。

Dask 会自动跟踪相关的计算和运行时指标。您可以利用这一点来帮助决定如何存储数据,以及在优化代码时应该关注哪些方面。

当然,计算成本不仅仅是计算时间。用户还应考虑通过网络传输数据的时间,工作节点内存占用情况,GPU/CPU 利用率以及磁盘 I/O 成本。这些因素帮助理解数据移动和计算流的更高层次洞见,比如工作节点中有多少内存用于存储尚未传递给下一个计算的先前计算,或者哪些特定函数占用了大部分时间。监控这些可以帮助优化集群和代码,同时还可以帮助识别可能出现的计算模式或逻辑瓶颈,从而进行调整。

Dask 的仪表板提供了大量统计数据和图表来回答这些问题。该仪表板是一个与您的 Dask 集群在运行时绑定的网页。您可以通过本地机器或运行它的远程机器访问它,方法我们在本章前面已经讨论过。在这里,我们将覆盖一些从性能指标中获取洞见并据此调整 Dask 以获得更好结果的方法。

Dask 仪表板

Dask 的仪表板包含许多不同页面,每个页面可以帮助理解程序的不同部分。

任务流

任务流仪表板提供了每个工作节点及其行为的高级视图。精确调用的方法以颜色代码显示,并可以通过缩放来检查它们。每行代表一个工作节点。自定义颜色的条形图是用户生成的任务,有四种预设颜色表示常见的工作节点任务:工作节点之间的数据传输、磁盘读写、序列化和反序列化时间以及失败的任务。图12-4 展示了分布在 10 个工作节点上的计算工作负载,平衡良好,没有一个工作节点完成较晚,计算时间均匀分布,并且最小化了网络 IO 开销。

Python-Dask-扩展指南-早期发布--全- - 绝不原创的飞龙 - 博客园 (10)

图 12-4. 带有良好平衡工作节点的任务流(数字版,彩色

另一方面,图12-5 展示了计算不均匀的情况。你可以看到计算之间有很多空白,这意味着工作人员被阻塞,并且在此期间实际上没有计算。此外,您可以看到一些工作人员开始较早,而其他人结束较晚,暗示代码分发中存在问题。这可能是由于代码本身的依赖性或子优化调整不当所致。改变 DataFrame 或数组块的大小可能会减少这些碎片化。您可以看到,每个工作人员启动工作时,他们处理的工作量大致相同,这意味着工作本身仍然相当平衡,并且分配工作负载带来了良好的回报。这是一个相当虚构的例子,因此此任务仅花了几秒钟,但相同的想法也适用于更长和更笨重的工作负载。

Python-Dask-扩展指南-早期发布--全- - 绝不原创的飞龙 - 博客园 (11)

图12-5. 任务流中有太多小数据块(数字,彩色版

内存

您可以监视内存使用情况,有时称为内存压力,⁷ 每个工作人员在“存储字节”部分的使用情况(参见图12-6)。这些默认情况下颜色编码,表示在限制内存压力、接近限制和溢出到磁盘。即使内存使用在限制内,当其超过 60%至 70%时,可能会遇到性能减慢。由于内存使用正在上升,Python 和 Dask 的内部将运行更昂贵的垃圾收集和内存优化任务,以防止其上升。

Python-Dask-扩展指南-早期发布--全- - 绝不原创的飞龙 - 博客园 (12)

图12-6. 监视 UI 中每个工作人员的内存使用情况(数字,彩色版

任务进度

你可以通过进度条看到任务完成的汇总视图,参见图12-7。执行顺序是从上到下,虽然这并不总是完全顺序的。条的颜色对调整特别信息丰富。在图12-7 中,sum()random_sample() 的实心灰色表示任务准备运行,依赖数据已准备好但尚未分配给工作人员。加粗的非灰色条表示任务已完成,结果数据等待下一个任务序列处理。较淡的非灰色块表示任务已完成,结果数据已移交并从内存中清除。您的目标是保持实心色块的可管理大小,以确保充分利用分配的大部分内存。

Python-Dask-扩展指南-早期发布--全- - 绝不原创的飞龙 - 博客园 (13)

图12-7. 按任务监视的进度,所有工作人员汇总(数字,彩色版

任务图

类似的信息也可以在任务图上找到(参见 图12-8),从单个任务的视角来看。您可能熟悉这些类似 MapReduce 的有向无环图。计算顺序从左到右显示,您的任务来源于许多并行工作负载,分布在工作人员之间,并以此种方式结束,最终得到由 10 个工作人员分布的结果。该图还准确地描述了任务依赖关系的低级视图。颜色编码还突出显示了计算生命周期中当前每个工作和数据所处的位置。通过查看这些信息,您可以了解哪些任务是瓶颈,因此可能是优化代码的良好起点。

Python-Dask-扩展指南-早期发布--全- - 绝不原创的飞龙 - 博客园 (14)

图 12-8. 显示每个任务的颜色编码状态及其前后任务的任务图(数字,彩色版本

工作人员标签页允许您实时查看 CPU、内存和磁盘 IO 等情况(参见 图12-9)。如果您怀疑您的工作人员内存不足或磁盘空间不足,监视此标签页可能会很有用。解决这些问题的一些方法可以包括为工作人员分配更多内存或选择不同的数据分块大小或方法。

图12-10 显示了工作事件监视。Dask 的分布式调度器在称为事件循环的循环上运行,该循环管理要安排的所有任务以及管理执行、通信和计算状态的工作人员。event_loop_interval 指标衡量了每个工作人员的此循环迭代之间的平均时间。较短的时间意味着调度器在为该工作人员执行其管理任务时花费的时间较少。如果此时间增加,可能意味着诸如网络配置不佳、资源争用或高通信开销等问题。如果保持较高,您可能需要查看是否为计算分配了足够的资源,并且可以为每个工作人员分配更大的资源或重新对数据进行分块。

Python-Dask-扩展指南-早期发布--全- - 绝不原创的飞龙 - 博客园 (15)

图 12-9. 具有 10 个工作人员的 Dask 集群的工作人员监视(数字,彩色版本

Python-Dask-扩展指南-早期发布--全- - 绝不原创的飞龙 - 博客园 (16)

图 12-10. Dask 集群的工作事件监视(数字,彩色版本

系统标签允许您跟踪 CPU、内存、网络带宽和文件描述符的使用情况。CPU 和内存易于理解。如果作业需要大量数据传输,那么 HPC 用户会特别关注网络带宽。这里的文件描述符跟踪系统同时打开的输入和输出资源数量。这包括实际打开的读/写文件,以及在机器之间通信的网络套接字。系统同时可以打开的描述符数量有限,因此一个非常复杂的作业或者开启了许多连接但未关闭的漏洞工作负载可能会造成问题。类似于内存泄漏,这会随着时间的推移导致性能问题。

Profile 标签允许您查看执行代码所花费的时间,可以精确到每次函数调用的细节,以聚合级别显示。这有助于识别造成瓶颈的任务。Figure12-11 显示了一个任务持续时间直方图,展示了每个任务及其所有调用的子例程的细粒度视图,以及它们的运行时间。这有助于快速识别比其他任务持续时间更长的任务。

Python-Dask-扩展指南-早期发布--全- - 绝不原创的飞龙 - 博客园 (17)

图 12-11. Dask 作业的任务持续时间直方图(数字,彩色版本
提示

您可以通过 Dask 客户端配置中的 distributed​.cli⁠ent.scheduler-info-interval 参数更改日志记录间隔。

保存和共享 Dask 指标/性能日志

您可以通过仪表板实时监控 Dask,但一旦关闭集群,仪表板将消失。您可以保存 HTML 页面,导出指标为 DataFrame,并编写用于指标的自定义代码(参见 Example12-9)。

示例 12-9. 生成并保存 Dask 仪表板至文件
from dask.distributed import performance_reportwith 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()

您可以为任何计算块手动生成性能报告,而无需保存整个运行时报告,只需使用 Example12-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_streamwith 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 dataframehistory_frame = pd.DataFrame( history, columns=[ 'worker', 'status', 'nbytes', 'thread', 'type', 'typename', 'metadata', 'startstops', 'key'])#plot task streamts.figure

高级诊断

您可以使用dask.distributed.diagnostics类插入自定义度量。其中一个函数是MemorySampler上下文管理器。当您在ms.sample()中运行您的 Dask 代码时,它会记录集群上的详细内存使用情况。示例12-11 虽然是人为的,但展示了如何在两种不同的集群配置上运行相同的计算,然后绘制以比较两个不同的环境配置。

示例 12-11. 为您的代码插入内存采样器
from distributed.diagnostics import MemorySamplerfrom dask_kubernetes import KubeClusterfrom distributed import Clientcluster = KubeCluster()client = Client(cluster)ms = MemorySampler()#some gnarly computegnarl = 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 scalingcluster.adapt(minimum=0, maximum=100)with ms.sample("memory with adaptive clusters"): y.compute()#plot the differencesms.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, LocalClusterclient = Client(cluster) # Connect to distributed cluster and override defaultd = {'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 retscores_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​.sched⁠uler.worker-saturation 相关的文档,以获取更多信息。

⁹ 您可以在 Dask 的文档中找到更多信息。

¹⁰ Holden 是 Kubeflow for Machine Learning(O’Reilly)的合著者,所以她在这里有偏见。

在本书中,我们根据需要简要介绍了一些分布式系统概念,但是当你准备独立进行工作时,复习一些 Dask 构建在其上的核心概念是一个好主意。在这个附录中,你将更多地了解 Dask 中使用的关键原则,以及它们如何影响你在 Dask 之上编写的代码。

测试通常是数据科学和数据工程中经常被忽视的一部分。我们的一些工具,如 SQL 和 Jupyter 笔记本,不鼓励测试或者使得测试变得容易——但这并不免除我们测试代码的责任。数据隐私问题可能会增加另一层挑战,我们不希望为测试而存储用户数据,这就要求我们努力创建“虚假”数据进行测试,或者将我们的代码分解为可测试的组件,这些组件不需要用户数据。

手动测试

我们在编写软件或数据工具时经常进行某种形式的手动测试。这可以包括简单地运行工具并眼睛观察结果,看看它们是否合理。手动测试很耗时,而且不会自动重复,所以虽然在开发过程中很棒,但对于长期项目来说是不够的。

单元测试

单元测试指的是测试单个代码单元,而不是整个系统一起测试。这要求你的代码被组织成不同的单元,如模块或函数。虽然在笔记本上这种做法较少见,但我们认为为了可测试性而结构化你的代码是一个好的实践。

为笔记本编写单元测试可能会很有挑战;文档测试在笔记本中稍微更容易内联。如果你想使用传统的单元测试库,ipython-unittest magics可以让你在笔记本中内联你的单元测试。

集成测试

集成测试指的是测试系统的不同部分如何一起工作。它通常更接近于代码的实际使用情况,但也可能更复杂,因为它涉及设置其他系统来进行测试。你可以在一定程度上使用一些相同的库进行集成测试,但这些测试往往需要更多的设置和拆卸工作。¹ 集成测试也更容易出现“不稳定”,因为在开始测试之前确保你的软件需要的所有不同组件在测试环境中都存在是具有挑战性的。

测试驱动开发

测试驱动开发涉及根据代码的需求或期望编写测试,然后再编写代码。对于数据科学管道来说,这通常可以通过创建样本输入(有时称为黄金集)来完成,并写出你期望的输出。测试驱动开发可能会很复杂,特别是当集成多个数据源时。

虽然您不需要使用测试驱动开发,但我们认为在开发数据流水线的同时进行测试非常重要。事后添加的测试总比没有测试好,但根据我们的经验,在开发过程中您拥有的上下文帮助您更好地创建测试(并且尽早验证您的假设)。

属性测试

属性测试可能是应对数据测试挑战的一个潜在的很好的解决方案,该解决方案涵盖了您的代码可能会出错的所有边缘情况的测试数据。与编写传统的“对于输入 A,预期结果 B”的方法不同,您可以指定属性,比如“如果我们有 0 个顾客,我们应该有 0 笔销售”或者“所有(有效的)顾客在此流水线后应该有欺诈评分”。

Hypothesis 是 Python 中最流行的属性测试库。

使用笔记本

测试笔记本是令人痛苦的,尽管它们极其受欢迎。一般来说,您可以选择在笔记本外进行测试,这样可以使用现有的 Python 测试库,或者尝试将测试放在笔记本内部。

外部笔记本测试

除了忽略测试之外,传统选项是将您想要测试的代码部分重构为单独的常规 Python 文件,并使用正常的测试库对其进行测试。虽然部分重构可能会很痛苦,但将代码重写为更易测试的组件也可以带来调试的好处。

testbook 项目 是重构的一种替代方法,采用了一种有趣的方法,允许您在笔记本外编写测试,而无需放弃笔记本。相反,您可以使用库装饰器来注释测试,例如,@testbook('untitled_7.ipynb', execute=True) 将在执行测试之前导入和执行笔记本。您还可以控制执行笔记本的哪些部分,但是这种部分执行可能在更新时很脆弱并容易中断。

笔记本内测试:内联断言

有些人喜欢在笔记本中使用内联断言作为测试的一种形式。在这种情况下,如果某些断言失败(例如,断言应该有一些顾客),那么笔记本的其余部分将不会运行。虽然我们认为使用内联断言很棒,但我们不认为它能替代传统的测试方法。

虽然良好的测试可以捕捉到许多问题,但有时现实世界比我们想象的更有创造力,我们的代码仍然会失败。在许多情况下,最糟糕的情况是我们的程序失败并生成一个我们不知道是错误的不正确输出,然后我们(或其他人)根据其结果采取行动。验证试图在我们的作业失败时通知我们,以便我们在其他人之前采取行动。在许多方面,这就像在提交学期论文之前运行拼写检查一样——如果有几个错误,那么好,但如果一切都是红色,最好再检查一遍。根据您的工作内容,验证它的方法会有所不同。

有许多不同的工具可以用来验证您的 Dask 作业的输出,当然包括 Dask 本身。一些工具,如 TFX 的数据验证,尝试比较先前版本的统计相似性和模式更改[²]。Pydantic 相对较新,但它具有 Dask 集成并且进行了出色的类型和模式验证。您还可以使用其假设组件进行更复杂的统计断言(这与 Python 的假设不同)。

机器学习模型在不影响用户的情况下更难验证,但统计技术仍然可以帮助(增量部署也可以)。由于机器学习模型是由数据生成的,验证数据是一个良好(部分)的步骤。

想想你的管道失败可能会带来什么影响是有用的。例如,你可能希望花更多时间验证一个管道,该管道决定临床试验中药物剂量,而不是预测哪个广告版本最成功[³]。

即使在分布式系统内部,也存在各种级别的“分布式”。Dask 是一个集中式分布式系统,其中有一个静态领导节点负责各种任务和协调工作人员之间的工作。在更分布式的系统中,没有静态领导节点,如果主节点消失,剩余的对等节点可以选举一个新的主节点,就像使用 ZooKeeper 一样。在更分布式的系统中,没有主节点的区别,集群中的所有节点在软件上(硬件可能不同)都是同等能力的。

集中式分布式系统倾向于更快,但在扩展方面遇到早期限制,并且在集中组件失败的挑战方面也有所挑战。

有很多不同的方法来分解我们的工作,在本书中,我们主要讨论了任务并行和数据并行。

任务并行

dask.delayed 和 Python 的多进程都代表了任务并行。通过任务并行,您不受限于执行相同的代码。任务并行提供了最大的灵活性,但需要更多的代码更改来充分利用它。

数据并行

数据并行指的是对不同数据块(或分区)上的相同操作进行并行运行。这是一种在 DataFrame 和数组上操作的优秀技术。数据并行依赖于分区来分割工作。我们在第四章详细介绍了分区。

洗牌和窄与宽转换

转换(或没有任何聚合或洗牌的数据并行)通常比 转换快得多,后者涉及洗牌或聚合。虽然这个术语借用自 Spark 社区,但区分(及其对容错性的影响)同样适用于 Dask 的数据并行操作。

限制

数据并行不太适合各种不同类型的工作。即使在处理数据问题时,也不适合执行多种不同的操作(非均匀计算)。数据并行通常不适合处理少量数据的计算,例如模型服务,可能需要逐个评估单个请求。

负载均衡

负载均衡是并行性的另一种视角,系统(或系统)将请求(或任务)路由到不同的服务器。负载均衡的范围从基本的轮询到“智能”负载均衡,利用关于相对负载、资源和工作服务器/服务器上数据的信息来调度任务。负载均衡越复杂,负载平衡器的工作量就越大。在 Dask 中,所有这些负载均衡都由中心处理,这要求主节点相对完整地查看大多数工作节点的状态以智能地分配任务。

另一个极端是“简单”的负载均衡,例如某些系统,如基于 DNS 轮询的负载均衡(Dask 未使用),没有任何关于系统负载的信息,只是选择“下一个”节点。当任务(或请求)在复杂性上大致相等时,基于轮询的负载均衡可以很好地工作。这种技术最常用于处理 Web 请求或外部 API 请求,其中您无法完全控制进行请求的客户端。您最有可能在模型服务中看到这一点,例如翻译文本或预测欺诈交易。

如果您搜索“分布式计算概念”,您可能会遇到 CAP 定理。CAP 定理对于分布式数据存储最为相关,但无论如何理解它都是有用的。该定理指出,我们无法构建一个既一致(Consistent)、可用(Available)、又分区容忍(Partition-tolerant)的分布式系统。分区可能由硬件故障或更常见的是由于过载的网络链路引起。

Dask 本身已经做出了不支持容错分区的折衷;网络分区的任一一侧拥有“领导者”,则该侧将继续运行,而另一侧则无法进展。

了解这如何应用于您从 Dask 访问的资源是很重要的。例如,您可能会发现自己处于一种情况中,即网络分区意味着 Dask 无法写入其输出。或者更糟糕的是,在我们看来,它可能导致您从 Dask 存储的数据被丢弃。⁴

由 Kyle Kingsbury 创建的Jepsen 项目是我们所知的用于测试分布式存储和查询系统的最佳项目之一。

递归是指调用自身的函数(直接或间接)。当它是间接的时候,被称为co-recursion,而返回最终值的递归函数被称为tail-recursive。⁵ 尾递归函数类似于循环,有时语言可以将尾递归调用转换为循环或映射。

有时会避免在无法优化递归的语言中使用递归函数,因为调用函数会有开销。相反,用户会尝试使用循环表达递归逻辑。

过度的非优化递归可能导致堆栈溢出错误。在 C、Java、C++等语言中,堆栈内存与主内存(也称为堆内存)分开分配。在 Python 中,递归的数量由set​recur⁠sionlimit控制。Python 提供了一个tail-recursive annotation,您可以使用它来帮助优化这些递归调用。

在 Dask 中,虽然递归调用没有完全相同的堆栈问题,但过度递归可能是头节点负载的原因之一。这是因为调度递归调用必须经过头节点,并且过多的递归函数会导致 Dask 的调度器在遇到任何堆栈大小问题之前变慢。

版本控制是一个重要的计算机科学概念,它可以应用于代码和数据。理想情况下,版本控制使得很容易撤销错误并返回到早期版本或同时探索多个方向。我们生产的许多物品都是我们的代码和数据的结合;为了真正实现快速回滚和支持实验的目标,您将希望对代码和数据都进行版本控制。

源代码的版本控制工具已经存在很长时间。对于代码来说,Git已经成为使用最广泛的开源版本控制系统,超过了诸如 Subversion、Concurrent Version Systems 等工具。

尽管深入理解 Git 可能非常复杂,⁶ 但对于常见用法,有几个 核心命令 经常能帮助你解决问题。本附录不涵盖 Git 的教学内容,但有许多资源可供参考,包括 Raju Gandhi(O'Reilly)的 Head First Git 和 Julia Evans 的 Oh sh*t, Git!,还有免费的在线资源。

不幸的是,软件版本控制工具目前的笔记本集成体验并不是最佳的,通常需要额外的工具,比如 ReviewNB,以便更好地理解变更。

现在,一个自然的问题是,你能否使用相同的工具对数据进行版本控制,就像你对软件做的那样?有时候可以——只要你的数据足够小并且不包含任何个人信息,使用源代码控制对数据进行管理是可以接受的。然而,软件通常存储在文本中,通常比你的数据要小,并且在文件开始超过几十 MB 后,许多源代码控制工具的效果并不理想。

相反,像 LakeFS 这样的工具在现有的外部数据存储(例如 S3、HDFS、Iceberg、Delta)之上添加了类似 Git 的版本控制语义。⁷ 另一种选择是手动复制你的表格,但我们发现这会导致命名笔记本和 Word 文档时常见的“-final2-really-final”问题。

到目前为止,我们已经讨论了能够拥有自己的 Python 包的隔离性,但还有更多种类的隔离。一些其他层次的隔离包括 CPU、GPU、内存和网络。⁸ 许多集群管理器并未提供完整的隔离性——这意味着如果你的任务被安排在错误的节点上,它们可能会表现出差劲的性能。解决这个问题的常见方法是按照整个节点的资源量请求资源,以避免在你自己的任务旁边安排其他任务。

严格的隔离也可能存在缺点,特别是如果隔离框架不支持突发性需求。严格的隔离如果没有突发性需求支持,可能会导致资源浪费,但对于关键任务工作流来说,这通常是一种权衡。

容错是分布式计算中的一个关键概念,因为你增加的计算机数量越多,每台计算机发生故障的概率就越高。在一些较小的 Dask 部署中,机器容错并不那么重要,因此,如果你仅在本地模式下或在两三台桌子底下的计算机上运行 Dask,你可能可以跳过本节内容。⁹

Dask 的核心容错方法是重新计算丢失的数据。这是许多现代数据并行系统选择的方法,因为故障并不是很常见,因此使没有故障的情况下快速恢复是首要任务。¹⁰

考虑 Dask 的容错性时,重要的是考虑 Dask 连接到的各个组件的故障条件可能性。虽然重新计算是分布式计算的一种良好方法,但分布式存储有不同的权衡。

Dask 对于在失败后重新计算的方法意味着用于计算的数据仍然存在以便需要时重新加载。在大多数系统中,这将是情况,但在某些流式系统中,您可能需要配置更长的 TTL 或者在顶部有一个缓冲区,以提供 Dask 所需的可靠性。另外,如果您正在部署自己的存储层(例如 MinIO),重要的是以一种方式部署它,以最小化数据丢失。

Dask 的容错性不包括领导节点。解决这个问题的部分方案通常称为高可用性,即 Dask 外部的系统监控并重启您的 Dask 领导节点。

在缩减规模时常常也会使用容错技术,因为容错和缩减规模都涉及节点的丢失。

可伸缩性指的是分布式系统处理更大问题并在需要减少时(例如研究生睡觉后)缩小的能力。在计算机科学中,我们通常将可伸缩性分类为水平垂直。水平扩展是指添加更多计算机,而垂直扩展是指使用更大的计算机。

另一个重要的考虑因素是自动扩展与手动扩展。在自动扩展中,执行引擎(在我们的情况下是 Dask)将为我们扩展资源。Dask 的自动扩展器将通过在需要时添加工作节点来进行水平扩展(前提是部署支持)。要进行垂直扩展,您可以向 Dask 的自动扩展器添加较大的实例类型,并在作业中请求这些资源。

注意

从某种意义上说,Dask 的任务“窃取”可以看作是一种自动垂直扩展的形式。如果一个节点无法(或特别慢)处理一个任务,那么另一个 Dask 工作节点可以“窃取”这个任务。在实践中,除非您安排了一个请求这些资源的任务,否则自动扩展器不会分配更高资源节点。

Dask 作业通常数据密集,将数据传输到 CPU(或 GPU)对性能影响很大。CPU 缓存通常比从内存读取快一个数量级以上。从 SSD 读取数据大约比从内存慢 4 倍,在数据中心内部发送数据可能慢约 10 倍。¹¹ CPU 缓存通常只能包含几个元素。

将数据从 RAM(甚至更糟的是从磁盘/网络)转移可能导致 CPU 停顿或无法执行任何有用的工作。这使得链式操作尤为重要。

计算机速度很快网站 通过真实代码很好地说明了这些性能影响。

哈希算法不仅在 Dask 中很重要,在计算机科学中也是如此。Dask 使用哈希算法将复杂的数据类型转换为整数,以便将数据分配给正确的分区。哈希通常是一个“单向”的操作,它将较大的键空间嵌入到较小的键空间中。对于许多操作,比如将数据分配给正确的分区,你希望哈希算法快速执行。然而,对于像假名化和密码这样的任务,你故意选择较慢的哈希算法,并经常增加更多迭代次数,以使其难以逆转。选择正确的哈希算法以匹配你的目的非常重要,因为不同的行为可能在一个用例中是一个特性,但在另一个用例中是一个错误。

对于简单的计算,数据传输成本可能会迅速超过数据计算成本。在可能的情况下,在已经具有数据的节点上安排任务通常会快得多,因为任务必须在某处安排(例如,无论如何都要支付复制任务的网络成本),但如果将任务放在正确的位置,则可以避免移动数据。网络复制通常也比磁盘慢。

client.submit 中,Dask 允许你指定一个期望的工作节点,通过 workers=。此外,如果你有数据将在各处访问,而不是进行常规的 scatter,你可以通过添加 broadcast=True 来广播它,以便所有工作节点都有集合的完整副本。

在大多数软件开发中,“一次性执行”这个概念是如此的普遍,以至于我们甚至不将其视为一个要求。例如,对银行账户的重复应用借记或贷记可能会是灾难性的。在 Dask 中实现一次性执行需要使用外部系统,因为 Dask 的容错方法。一个常见的方法是使用数据库(分布式或非分布式)以及事务来确保一次性执行。

并非所有的分布式系统都有这个挑战。输入和输出受控制,并通过冗余写入实现容错的系统在执行上一次时更容易。一些使用失败后重新计算的系统仍能通过集成分布式锁提供一次性执行。

分布式系统很有趣,但正如你从分布式系统的概念中看到的那样,它们增加了大量的开销。如果你不需要分布式系统,那么在本地模式下使用 Dask 并使用本地数据存储可以极大地简化你的生活。无论你选择本地模式还是分布式模式,对一般系统概念的了解都将帮助你构建更好的 Dask 流水线。

¹ 这可以包括创建数据库,填充数据,启动集群服务等。

² 我们不建议在新环境中使用 TFX,因为可能很难启动。

³ 我们承认社会通常不是这样构建的。

⁴ 这不是数据库最常见的容错方式,但一些常见数据库的默认配置可能导致这种情况。

间接在这里意味着在两个函数之间;例如,“A 调用 B,B 调用 A”是共递归的一个例子。

⁶ 一部经典的XKCD 漫画出人意料地接近捕捉我们在 Git 早期经历中的经验。

⁷ 利益冲突披露:Holden 已从 LakeFS 项目获得 T 恤衫和贴纸。一些替代方案包括专注于 Iceberg 表的 Nessie 项目。

⁸ 例如,同一个节点上的两个 ML 任务可能都会尝试使用所有的 CPU 资源。

⁹ 我们在这里选择了三个,因为没有驱动程序的工作节点失败的概率仅为驱动程序的两倍(我们无法恢复),并且随着添加更多机器,这种比例呈线性增长。

¹⁰ 您可以缓存中间步骤以减少重新计算的成本,但前提是缓存位置未失败,并且需要清理任何缓存。

¹¹ 精确的性能数字取决于您的硬件。

Dask 的分布式类似于 pandas 的 DataFrame,在我们看来是其关键特性之一。存在各种方法提供可扩展的类似 DataFrame 的功能。使得 Dask 的 DataFrame 脱颖而出的一个重要因素是对 pandas API 的高度支持,其他项目正在迅速赶上。本附录比较了一些不同的当前和历史数据框架库。

要理解这些差异,我们将看几个关键因素,其中一些与我们在第八章中建议的技术类似。首先是 API 的外观,以及使用 pandas 的现有技能和代码可以转移多少。然后我们将看看有多少工作被强制在单个线程、驱动程序/主节点上进行,然后在单个工作节点上进行。

可扩展数据框架并不一定意味着分布式,尽管分布式扩展通常允许处理比单机选项更大的数据集更经济实惠,并且在真正大规模的情况下,这是唯一的实际选择。

许多工具中常见的一个依赖是它们建立在 ASF Arrow 之上。虽然 Arrow 是一个很棒的项目,我们希望看到它持续被采纳,但它在类型差异方面有些差异,特别是在可空性方面。¹ 这些差异意味着大多数使用 Arrow 构建的系统共享一些共同的限制。

开放多处理(OpenMP)和开放消息传递接口(OpenMPI)是许多这些工具依赖的另外两个常见依赖项。尽管它们有类似的缩写,你通常会看到它们被称为,但它们采用了根本不同的并行化方法。OpenMP 是一个专注于共享内存的单机工具(可能存在非均匀访问)。OpenMPI 支持多台机器,而不是共享内存,使用消息传递(在概念上类似于 Dask 的 Actor 系统)进行并行化。

仅限单机

单机可扩展数据框架专注于并行化计算或允许数据不同时驻留在内存中(例如,一些可以驻留在磁盘上)。在某种程度上,这种“数据可以驻留在磁盘上”的方法可以通过操作系统级别的交换文件来解决,但实际上,让库在元素的智能页面进出中进行智能分页也具有其优点。

Pandas

在讨论缩放 DataFrame 的部分中提到 pandas 可能看起来有些愚蠢,但记住我们比较的基准是什么是有用的。总体而言,Pandas 是单线程的,要求所有数据都适合单台机器的内存。可以使用各种技巧来处理 pandas 中更大的数据集,如创建大交换文件或逐个处理较小的块。需要注意的是,许多这些技术都已纳入用于扩展 pandas 的工具中,因此如果您需要这样做,现在可能是开始探索扩展选项的时候了。另一方面,如果在 pandas 中一切正常运行,通过使用 pandas 本身可以获得 100% 的 pandas API 兼容性,这是其他选项无法保证的。另外,pandas 是直接要求,而不是可扩展 pandas 工具之一

H2O 的 DataTable

DataTable 是一个类似于 DataFrame 的单机尝试,旨在扩展处理能力达到 100 GB(尽管项目作者将其描述为“大数据”,我们认为它更接近中等规模数据)。尽管是为 Python 设计的,DataTable 并没有简单复制 pandas 的 API,而是致力于继承很多 R 的 data.table API。这使得它对于来自 R 的团队来说可能是一个很好的选择,但对于专注于 pandas 的用户来说可能不太吸引人。DataTable 也是一个单公司开源项目,存放在 H2O 的 GitHub 上,而不是在某个基金会或自己的平台上。在撰写本文时,它的开发活动相对集中。它有积极的持续集成(在 PR 进来时运行),我们认为这表明它是高质量的软件。DataTable 可以使用 OpenMP 在单台机器上并行计算,但不要求使用 OpenMP。

Polars

Polars 是另一个单机可扩展的 DataFrame,但它采用的方法是在 Rust 中编写其核心功能,而不是 C/C++ 或 Fortran。与许多分布式 DataFrame 工具类似,Polars 使用 ASF 的 Arrow 项目来存储 DataFrame。同样,Polars 使用惰性评估来管道化操作,并在内部分区/分块 DataFrame,因此(大部分时间)只需在任一时间内内存中保留数据的子集。Polars 在所有单机可扩展 DataFrame 中拥有最大的开发者社区。Polars 在其主页上链接到基准测试,显示其比许多分布式工具快得多,但仅当将分布式工具约束为单机时才有意义,这是不太可能的。它通过使用单台机器中的所有核心来实现其并行性。Polars 拥有详尽的文档,并且还有一个明确的章节,介绍从常规 pandas 迁移时可以期待的内容。它不仅具有持续集成,而且还将基准测试集成为每个 PR 的一部分,并针对多个版本的 Python 和环境进行测试。

分布式

扩展 DataFrame 的大多数工具都具有分布式的特性,因为在单个机器上的所有花哨技巧只能带来有限的效果。

ASF Spark DataFrame

Spark 最初以所谓的弹性分布式数据集(RDD)起步,然后迅速添加了更类似于 DataFrame 的 API,称为 DataFrames。这引起了很多兴奋,但许多人误解它是指“类似于 pandas”,而 Spark 的(最初的)DataFrames 更类似于“类似于 SQL 的”DataFrames。Spark 主要用 Scala 和 Java 编写,两者都运行在 Java 虚拟机(JVM)上。虽然 Spark 有 Python API,但它涉及 JVM 和 Python 之间大量数据传输,这可能很慢,并且可能增加内存需求。Spark DataFrames 在 ASF Arrow 之前创建,因此具有其自己的内存存储格式,但后来添加了对 Arrow 在 JVM 和 Python 之间通信的支持。

要调试 PySpark 错误尤其困难,因为一旦出错,你会得到一个 Java 异常和一个 Python 异常。

SparklingPandas

由于 Holden 共同编写了 SparklingPandas,我们可以自信地说不要使用这个库,而不必担心会有人不高兴。SparklingPandas 建立在 ASF Spark 的 RDD 和 DataFrame API 之上,以提供更类似于 Python 的 API,但由于其标志是一只熊猫在便签纸上吃竹子,你可以看到我们并没有完全成功。SparklingPandas 确实表明通过重用 pandas 的部分内容可以提供类似 pandas 的体验。

对于尴尬并行类型的操作,通过使用 map 将 pandas API 的每个函数添加到每个 DataFrame 上,Python 代码的委托非常快速。一些操作,如 dtypes,仅在第一个 DataFrame 上评估。分组和窗口操作则更为复杂。

由于最初的合著者有其他重点领域的日常工作,项目未能超越概念验证阶段。

Spark Koalas / Spark pandas DataFrames

Koalas 项目最初源自 Databricks,并已整合到 Spark 3.2 中。Koalas 采用类似的分块 pandas DataFrames 方法,但这些 DataFrames 表示为 Spark DataFrames 而不是 Arrow DataFrames。像大多数系统一样,DataFrames 被延迟评估以允许流水线处理。Arrow 用于将数据传输到 JVM 并从中传输数据,因此您仍然具有 Arrow 的所有类型限制。这个项目受益于成为一个庞大社区的一部分,并与传统的大数据堆栈大部分互通。这源自于作为 JVM 和 Hadoop 生态系统的一部分,但这也会带来性能上的一些不利影响。目前,在 JVM 和 Python 之间移动数据会增加开销,而且总体上,Spark 专注于支持更重的任务。

在 Spark Koalas / Spark pandas DataFrames 上的分组操作尚不支持部分聚合。这意味着一个键的所有数据必须适合一个节点。

Cylon

Cylon 的主页非常专注于基准测试,但它选择的基准测试(将 Cylon 与 Spark 在单机上进行比较)很容易达到,因为 Spark 是设计用于分布式使用而不是单机使用。Cylon 使用 PyArrow 进行存储,并使用 OpenMPI 管理其任务并行性。Cylon 还有一个名为 GCylon 的 GPU 后端。PyClon 的文档还有很大的改进空间,并且当前的 API 文档链接已经失效。

Cylon 社区似乎每年有约 30 条消息,试图找到任何使用 DataFrame 库的开源用户 没有结果贡献者文件 和 LinkedIn 显示大多数贡献者都来自同一所大学。

该项目遵循几个软件工程的最佳实践,如启用 CI。尽管如此,相对较小(明显活跃)的社区和缺乏清晰的文档意味着,在我们看来,依赖 Cylon 可能比其他选项更复杂。

Ibis

Ibis 项目 承诺“结合 Python 分析的灵活性和现代 SQL 的规模与性能”。它将你的代码编译成类似 pandas 的 SQL 代码(尽可能),这非常方便,因为许多大数据系统(如 Hive、Spark、BigQuery 等)支持 SQL,而且 SQL 是目前大多数数据库的事实标准查询语言。不幸的是,SQL 的实现并不统一,因此在不同后端引擎之间移动可能会导致故障,但 Ibis 在 跟踪哪些 API 适用于哪些后端引擎 方面做得很好。当然,这种设计限制了你可以在 SQL 中表达的表达式类型。

Modin

与 Ibis 类似,Modin 与许多其他工具略有不同,它具有多个分布式后端,包括 Ray、Dask 和 OpenMPI。Modin 的宣称目标是处理从 1 MB 到 1+ TB 的数据,这是一个广泛的范围。Modin 的主页 还声称可以“通过更改一行代码扩展您的 pandas 工作流”,虽然这种说法有吸引力,但在我们看来,它对 API 兼容性和利用并行和分布式系统所需的知识要求做出了过多的承诺。³ 在我们看来,Modin 很令人兴奋,因为每个分布式计算引擎都有自己重新实现 pandas API 的需求看起来很愚蠢。Modin 有一个非常活跃的开发者社区,核心开发者来自多个公司和背景。另一方面,我们认为当前的文档并没有很好地帮助用户理解 Modin 的局限性。幸运的是,您对 Dask DataFrames 的大部分直觉在 Modin 中仍然适用。我们认为 Modin 对需要在不同计算引擎之间移动的个人用户来说是理想选择。

警告

与其他系统不同,Modin 被积极评估,这意味着它不能利用自动流水线处理您的计算。

Vanilla Dask DataFrame

我们在这里有偏见,但我们认为 Dask 的 DataFrame 库在平衡易于入门和明确其限制方面做得非常好。Dask 的 DataFrames 拥有来自多家不同公司的大量贡献者。Dask DataFrames 还具有相对高水平的并行性,包括对分组操作的支持,在许多其他系统中找不到。

cuDF

cuDF 扩展了 Dask DataFrame,以支持 GPU。然而,它主要是一个单一公司项目,来自 NVIDIA。这是有道理的,因为 NVIDIA 希望卖更多的 GPU,但这也意味着它不太可能很快为 AMD GPU 添加支持。如果 NVIDIA 继续认为为数据分析销售更多 GPU 是最佳选择的话,该项目可能会得到维护,并保持类似 pandas 的接口。

cuDF 不仅具有 CI,而且具有区域责任的强大代码审查文化。

在理想的世界中,会有一个明确的赢家,但正如你所见,不同的可扩展 DataFrame 库为不同目的提供服务,除了那些已经被放弃的,所有都有潜在的用途。我们认为所有这些库都有其位置,取决于您的确切需求。

¹ Arrow 允许所有数据类型为 null。Pandas 不允许整数列包含 null。当将 Arrow 文件读取为 pandas 时,如果一个整数列不包含 null,它将被读取为整数在 pandas DataFrame 中,但如果在运行时遇到 null,则整个列将被读取为浮点数。

² 除了我们自己之外,如果你正在阅读这篇文章,你可能已经帮助 Holden 买了一杯咖啡,那就足够了。😃

³ 例如,看看关于 groupBy + apply 的限制混乱,除了 GitHub 问题 外,没有其他文档。

根据您的调试技术,转向分布式系统可能需要一套新的技术。虽然您可以在远程模式下使用调试器,但通常需要更多的设置工作。您还可以在本地运行 Dask,以在许多其他情况下使用现有的调试工具,尽管——从我们的经验来看——令人惊讶的许多难以调试的错误在本地模式下并不会显现。Dask 采用了特殊的混合方法。一些错误发生在 Python 之外,使得它们更难以调试,如容器内存不足 (OOM) 错误、段错误和其他本地错误。

注意

这些建议中有些适用于分布式系统,包括 Ray 和 Apache Spark。因此,本章的某些部分与 High Performance Spark, 第二版 和 Scaling Python with Ray 有共通之处。

在 Dask 中使用调试器有几种不同的选项。PyCharm 和 PDB 都支持连接到远程调试器进程,但是找出任务运行的位置并设置远程调试器可能会有挑战。有关 PyCharm 远程调试的详细信息,请参阅 JetBrains 文章 “使用 PyCharm 远程调试”。一种选择是使用 epdb 并在 actor 中运行 import epdb; epdb.serve()。最简单的选项是通过在失败的 future 上运行 client.recreate_error_locally 来让 Dask 在本地重新运行失败的任务,尽管这并非完美解决方案。

您可能有自己的标准 Python 代码调试技术,并且这些技术并非替代它们。一些在 Dask 中有帮助的一般技术包括以下内容:

  • 将失败的函数分解为更小的函数;更小的函数使问题更容易隔离。

  • 要小心引用函数外的变量,可能会导致意外的作用域捕获,序列化更多的数据和对象。

  • 抽样数据并尝试在本地复现(本地调试通常更容易)。

  • 使用 mypy 进行类型检查。虽然我们的示例中未包含类型信息以节省空间,但在生产代码中,宽松的类型使用可以捕捉到棘手的错误。

  • 难以追踪任务的调度位置?Dask actors 无法移动,因此可以使用 actor 将所有调用保持在一台机器上进行调试。

  • 当问题出现时,无论并行化如何,通过在本地单线程模式下调试您的代码,可以更容易地理解正在发生的事情。

使用这些提示,通常可以在熟悉的环境中找到自己,以使用传统的调试工具,但某些类型的错误可能会更复杂一些。

由于容器错误的原因相同,本地错误和核心转储可能很难调试。由于这些类型的错误通常导致容器退出,因此访问调试信息可能变得困难。根据您的部署方式,可能有一个集中式日志聚合器,收集来自容器的所有日志,尽管有时这些日志可能会错过最后几部分(这些部分很可能是您最关心的)。这个问题的一个快速解决方案是在启动脚本中添加一个 sleep(在失败时),以便您可以连接到容器(例如,[dasklaunchcommand] || sleep 100000)并使用本地调试工具。

然而,访问容器的内部可能并不像说起来那么容易。在许多生产环境中,出于安全原因,您可能无法远程访问(例如,在 Kubernetes 上使用 kubectl exec)。如果是这种情况,您可以(有时)向容器规范添加一个关闭脚本,将核心文件复制到容器关闭后仍然存在的位置(例如,s3HDFSNFS)。您的集群管理员可能还推荐了一些工具来帮助调试(如果没有的话,他们可能能够为您的组织创建一个推荐的路径)。

Dask 的 官方调试指南 建议手动移除失败的 futures。当加载可以分块处理而不是一次加载整个分区的数据时,返回具有成功和失败数据的元组更好,因为移除整个分区不利于确定根本原因。这种技术在 示例C-1 中有所示。

示例 C-1. 处理错误数据的替代方法
# Handling some potentially bad data; this assumes line-by-lineraw_chunks = bag.read_text( urls, files_per_partition=1, linedelimiter="helloworld")def maybe_load_data(data): try: # Put your processing code here return (pandas.read_csv(StringIO(data)), None) except Exception as e: return (None, (e, data))data = raw_chunks.map(maybe_load_data)data.persist()bad_data = data.filter(lambda x: x[0] is None)good_data = data.filter(lambda x: x[1] is None)
注意

这里的坏记录不仅仅指加载或解析失败的记录;它们也可能是导致您的代码失败的记录。通过遵循这种模式,您可以提取有问题的记录进行深入调查,并使用它来改进您的代码。

Dask 为 distributedlocal 调度器都内置了诊断工具。本地诊断工具具有更丰富的功能,几乎涵盖了所有调试的部分。这些诊断工具在您看到性能逐渐下降的调试情况下尤其有用。

注意

在创建 Dask 客户端时,很容易因错误而意外地使用 Dask 的分布式本地后端,因此,如果您没有看到您期望的诊断结果,请确保明确指定您正在运行的后端。

在 Dask 中启动调试工具会需要更多工作,而且在可能的情况下,Dask 的本地模式提供了远程调试的绝佳替代方案。并非所有错误都一样,而且一些错误,比如本地代码中的分段错误,尤其难以调试。祝你找到(们)bug 的好运;我们相信你。

本书专注于使用 Dask 构建批处理应用程序,其中数据是从用户收集或提供的,并用于计算。 另一组重要的用例是需要在数据变得可用时处理数据的情况。¹ 处理数据变得可用时称为流式处理。

由于人们对其数据驱动产品有更高期望,流数据管道和分析变得越来越受欢迎。 想象一下,如果银行交易需要数周才能完成,那将显得极其缓慢。 或者,如果您在社交媒体上阻止某人,您期望该阻止立即生效。 虽然 Dask 擅长交互式分析,但我们认为它(目前)并不擅长对用户查询做出即时响应。²

流式作业与批处理作业在许多重要方面有所不同。 它们往往需要更快的处理时间,并且这些作业本身通常没有定义的终点(除非公司或服务被关闭)。 小批处理作业可能无法胜任的一种情况包括动态广告(几十到几百毫秒)。 许多其他数据问题可能会模糊界限,例如推荐系统,其中您希望根据用户互动更新它们,但是几分钟的延迟可能(主要)是可以接受的。

如 第八章 中所讨论的,Dask 的流式组件似乎比其他组件使用频率低。 在 Dask 中,流式处理在一定程度上是事后添加的,³ 在某些场合和时间,您可能会注意到这一点。 当加载和写入数据时,这一点最为明显 —— 一切都必须通过主客户端程序移动,然后分散或收集。

警告

目前,Streamz 无法处理比客户端计算机内存中可以容纳的更多数据。

在本附录中,您将学习 Dask 流处理的基本设计,其限制以及与其他一些流式系统的比较。

注意

截至本文撰写时,Streamz 在许多地方尚未实现 ipython_display,这可能导致在 Jupyter 中出现类似错误的消息。 您可以忽略这些消息(它会回退到 repr)。

安装 Streamz 很简单。 它可以从 PyPI 获得,并且您可以使用 pip 安装它,尽管像所有库一样,您必须在所有工作节点上可用。 安装完 Streamz 后,您只需创建一个 Dask 客户端(即使是在本地模式下),然后导入它,如 示例D-1 所示。

示例 D-1. 开始使用 Streamz
import daskimport dask.dataframe as ddfrom streamz import Streamfrom dask.distributed import Clientclient = Client()
注意

当存在多个客户端时,Streamz 使用创建的最近的 Dask 客户端。

到目前为止,在本书中,我们从本地集合或分布式文件系统加载了数据。虽然这些数据源确实可以作为流数据的来源(有一些限制),但在流数据世界中存在一些额外的数据源。流数据源不同于有定义结束的数据源,因此行为更像生成器而不是列表。流接收器在概念上类似于生成器的消费者。

注意

Streamz 的接收端(或写入目的地)支持有限,这意味着在许多情况下,您需要使用自己的函数以流方式将数据写回。

一些流数据源具有重放或回溯已发布消息的能力(可配置的时间段),这对基于重新计算的容错方法尤为有用。两个流行的分布式数据源(和接收器)是 Apache Kafka 和 Apache Pulsar,两者都具备回溯查看先前消息的能力。一个没有此能力的示例流系统是 RabbitMQ。

Streamz API 文档 涵盖了支持的数据源;为简便起见,我们这里聚焦于 Apache Kafka 和本地可迭代数据源。Streamz 在主进程中进行所有加载,然后您必须分散结果。加载流数据应该看起来很熟悉,加载本地集合的示例见 示例 D-2,加载 Kafka 的示例见 示例 D-3。

示例 D-2. 加载本地迭代器
local_stream = Stream.from_iterable( ["Fight", "Flight", "Freeze", "Fawn"])dask_stream = local_stream.scatter()
示例 D-3. 从 Kafka 加载
batched_kafka_stream = Stream.from_kafka_batched( topic="quickstart-events", dask=True, # Streamz will call scatter internally for us max_batch_size=2, # We want this to run quickly, so small batches consumer_params={ 'bootstrap.servers': 'localhost:9092', 'auto.offset.reset': 'earliest', # Start from the start # Consumer group id # Kafka will only deliver messages once per consumer group 'group.id': 'my_special_streaming_app12'}, # Note some sources take a string and some take a float :/ poll_interval=0.01) 

在这两个示例中,Streamz 将从最近的消息开始读取。如果您希望 Streamz 回到存储消息的起始位置,则需添加 py ``

警告

Streamz 在单头进程上进行读取是可能遇到瓶颈的地方,尤其在扩展时。

与本书的其余部分一样,我们假设您正在使用现有的数据源。如果情况不是这样,请查阅 Apache Kafka 或 Apache Pulsar 文档(以及 Kafka 适配器)以及 Confluent 的云服务。

没有流部分会完整无缺地涉及到词频统计,但重要的是要注意我们在 示例 D-4 中的流式词频统计——除了数据加载的限制外——无法以分布式方式执行聚合。

示例 D-4. 流式词频统计
local_wc_stream = (batched_kafka_stream # .map gives us a per batch view, starmap per elem .map(lambda batch: map(lambda b: b.decode("utf-8"), batch)) .map(lambda batch: map(lambda e: e.split(" "), batch)) .map(list) .gather() .flatten().flatten() # We need to flatten twice. .frequencies() ) # Ideally, we'd call flatten frequencies before the gather, # but they don't work on DaskStreamlocal_wc_stream.sink(lambda x: print(f"WC {x}"))# Start processing the stream now that we've defined our sinksbatched_kafka_stream.start()

在前述示例中,您可以看到 Streamz 的一些当前限制,以及一些熟悉的概念(如 map)。如果您有兴趣了解更多,请参阅 Streamz API 文档;然而,请注意,根据我们的经验,某些组件在非本地流上可能会随机失效。

如果您正在使用 GPU,cuStreamz 项目 简化了 cuDF 与 Streamz 的集成。cuStreamz 使用了许多自定义组件来提高性能,例如将数据从 Kafka 加载到 GPU 中,而不是先在 Dask DataFrame 中着陆,然后再转换。cuStreamz 还实现了一个灵活性比默认 Streamz 项目更高的自定义版本的检查点技术。该项目背后的开发人员大多受雇于希望向您销售更多 GPU 的人,声称可以实现高达 11 倍的加速

大多数流处理系统都具有某种形式的状态检查点,允许在主控程序失败时从上一个检查点重新启动流应用程序。Streamz 的检查点技术仅限于不丢失任何未处理的记录,但累积状态可能会丢失。如果您的状态随着时间的推移而累积,则需要您自己构建状态检查点/恢复机制。这一点尤为重要,因为在足够长的时间窗口内,遇到单个故障点的概率接近 100%,而流应用程序通常打算永久运行。

这种无限期运行时间导致了许多其他挑战。小内存泄漏会随着时间的推移而累积,但您可以通过定期重新启动工作进程来减轻它们。

流式处理程序通常在处理聚合时会遇到晚到达的数据问题。这意味着,虽然您可以根据需要定义窗口,但您可能会遇到本该在窗口内的记录,但由于时间原因未及时到达处理过程。Streamz 没有针对晚到达数据的内置解决方案。您的选择是在处理过程中手动跟踪状态(并将其持久化到某处),忽略晚到达的数据,或者使用另一个支持晚到达数据的流处理系统(包括 kSQL、Spark 或 Flink)。

在某些流应用程序中,确保消息仅被处理一次很重要(例如,银行账户)。由于失败时需要重新计算,Dask 通常不太适合这种情况。这同样适用于在 Dask 上的 Streamz,其中唯一的选项是 至少一次 执行。您可以通过使用外部系统(如数据库)来跟踪已处理的消息来解决此问题。

在我们看来,在 Dask 内支持流数据的 Streamz 取得了有趣的开始。它目前的限制使其最适合于流入数据量较小的情况。尽管如此,在许多情况下,流数据量远小于面向批处理数据的量,能够在一个系统中同时处理二者可以避免重复的代码或逻辑。如果 Streamz 不能满足您的需求,还有许多其他的 Python 流处理系统可供选择。您可能希望了解的一些 Python 流处理系统包括 Ray 流处理、Faust 或 PySpark。根据我们的经验,Apache Beam 的 Python API 比 Streamz 有更大的发展空间。

¹ 尽管通常会有一些(希望是小的)延迟。

² 尽管这两者都是“互动的”,但访问您的网站并下订单的人的期望与数据科学家试图制定新广告活动的期望有很大不同。

³ Spark 流处理也是如此,但 Dask 的流处理甚至比 Spark 的流处理集成性更低。

Python-Dask-扩展指南-早期发布--全- - 绝不原创的飞龙 - 博客园 (2024)
Top Articles
Grasshopper Brownies Recipe
Spinach Risotto With Taleggio Recipe
Spasa Parish
Rentals for rent in Maastricht
159R Bus Schedule Pdf
Sallisaw Bin Store
Black Adam Showtimes Near Maya Cinemas Delano
Espn Transfer Portal Basketball
Pollen Levels Richmond
11 Best Sites Like The Chive For Funny Pictures and Memes
Things to do in Wichita Falls on weekends 12-15 September
Craigslist Pets Huntsville Alabama
Paulette Goddard | American Actress, Modern Times, Charlie Chaplin
Red Dead Redemption 2 Legendary Fish Locations Guide (“A Fisher of Fish”)
What's the Difference Between Halal and Haram Meat & Food?
R/Skinwalker
Rugged Gentleman Barber Shop Martinsburg Wv
Jennifer Lenzini Leaving Ktiv
Ems Isd Skyward Family Access
Elektrische Arbeit W (Kilowattstunden kWh Strompreis Berechnen Berechnung)
Omni Id Portal Waconia
Kellifans.com
Banned in NYC: Airbnb One Year Later
Four-Legged Friday: Meet Tuscaloosa's Adoptable All-Stars Cub & Pickle
Model Center Jasmin
Ice Dodo Unblocked 76
Is Slatt Offensive
Labcorp Locations Near Me
Storm Prediction Center Convective Outlook
Experience the Convenience of Po Box 790010 St Louis Mo
Fungal Symbiote Terraria
modelo julia - PLAYBOARD
Poker News Views Gossip
Abby's Caribbean Cafe
Joanna Gaines Reveals Who Bought the 'Fixer Upper' Lake House and Her Favorite Features of the Milestone Project
Tri-State Dog Racing Results
Navy Qrs Supervisor Answers
Trade Chart Dave Richard
Lincoln Financial Field Section 110
Free Stuff Craigslist Roanoke Va
Wi Dept Of Regulation & Licensing
Pick N Pull Near Me [Locator Map + Guide + FAQ]
Crystal Westbrooks Nipple
Ice Hockey Dboard
Über 60 Prozent Rabatt auf E-Bikes: Aldi reduziert sämtliche Pedelecs stark im Preis - nur noch für kurze Zeit
Wie blocke ich einen Bot aus Boardman/USA - sellerforum.de
Infinity Pool Showtimes Near Maya Cinemas Bakersfield
Dermpathdiagnostics Com Pay Invoice
How To Use Price Chopper Points At Quiktrip
Maria Butina Bikini
Busted Newspaper Zapata Tx
Latest Posts
Article information

Author: Kimberely Baumbach CPA

Last Updated:

Views: 5952

Rating: 4 / 5 (41 voted)

Reviews: 80% of readers found this page helpful

Author information

Name: Kimberely Baumbach CPA

Birthday: 1996-01-14

Address: 8381 Boyce Course, Imeldachester, ND 74681

Phone: +3571286597580

Job: Product Banking Analyst

Hobby: Cosplaying, Inline skating, Amateur radio, Baton twirling, Mountaineering, Flying, Archery

Introduction: My name is Kimberely Baumbach CPA, I am a gorgeous, bright, charming, encouraging, zealous, lively, good person who loves writing and wants to share my knowledge and understanding with you.