Python-Dask-扩展指南-早期发布--一-

235 阅读1小时+

Python Dask 扩展指南(早期发布)(一)

原文: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 联系我们。

O'Reilly 在线学习

注意

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

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

如何联系我们

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

我们为这本书建立了一个网页,列出了勘误、示例和任何额外信息。您可以访问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 一个起床的理由(尽管对她来说有时候会太早)。

spwd 00in01

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

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

第一章:什么是 Dask?

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

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

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

为什么需要使用 Dask?

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

注意

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

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 是很多东西,但它不是你可以挥舞在代码上使其更快的魔术棒。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。使用这种方式,我们将探索一些更为简单的并行计算任务(包括大家喜爱的单词统计)。¹

在本地安装 Dask

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

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

Example 2-1. 在新的 conda 环境中安装 Dask
conda create -n dask python=3.8.6  mamba -y
conda activate dask
mamba 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。

Hello Worlds

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

Example 2-2. 使用 LocalCluster 启动 Dask
import dask
from dask.distributed import Client
client = 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 timeit

def slow_task(x):
    import time
    time.sleep(2) # Do something sciency/business
    return x

things = 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.delayed
def 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 links

dask.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 da
distributed_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 db
githubs = [
    "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.delayed
def 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 有许多不同的执行后端,但我们发现最容易将它们归为两组:本地和分布式。使用本地后端,您的规模受限于单台计算机所能处理的范围。本地后端还具有诸如避免网络开销、更简单的库管理和更低的成本等优势。¹ 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 工作节点插件接受一个包列表,并在所有工作节点上在运行时安装它们。回顾 Example 2-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 的诊断用户界面

在理解程序执行的第一步应该是使用 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。

spwd 0301

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

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

序列化和 Pickling

分布式和并行系统依赖于序列化,在 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.delayed
def bad_fun(x):
    return ConnectionClass("www.scalingpythonml.com", 80)

# Fails to serialize
if 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 x

f = 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 的结果。

spwd 0302

图 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 一起可视化,你将在 Figure 3-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_of
list(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 时重试。

第四章:Dask DataFrame

虽然 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 的构建方式

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 connect
from sqlalchemy import sql
import dask.dataframe as dd

#sqlite connection
db_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 db
ddf = dd.read_sql_query(select_stmt,
                        npartitions=4,
                        index_col=col_student_num,
                        con=db_conn)

#alternatively, read whole table
ddf = dd.read_sql_table("transcripts",
                        db_conn,
                        index_col="student_number",
                        npartitions=4
                        )

#do_some_ETL...

#save to db
ddf.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_implementations
known_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 chunk
def process_chunk(chunk):
    def weighted_func(df):
        return (df["EmployerSize"] * df["DiffMeanHourlyPercent"]).sum()
    return (chunk.apply(weighted_func), chunk.sum()["EmployerSize"])

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

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

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

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

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

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

Dask 的内置完整 DataFrame 聚合使用一个称为apply_contact_apply的低级接口进行部分聚合。与学习两种不同的部分聚合 API 相比,我们更喜欢通过提供一个常量分组函数来进行静态的groupby。这样,我们只需了解一个聚合的接口。您可以使用此方法在 DataFrame 中查找聚合 COVID-19 数字,如示例 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.Aggregation

from dask.dataframe import hyperloglog

approx_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 疫情开始到今天最多七天的滚动窗口的日期范围,请参见 Example 4-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 函数有能力为给定的目标大小选择分割点,就像在 Example 4-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,以及许多逐行操作。如果你想使用一个你找不到的逐行操作,你可以自己实现,就像在 Example 4-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
# information
new_df.clear_divisions()

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

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

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

警告

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

处理多个 DataFrame

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 都在积极开发中。一个例子是来自 Example 2-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 进行数据科学:将其放在一起

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 pd

pd.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 plt
import seaborn as sns 
import numpy as np

get_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 如何利用这些构建模块支持数据科学与数据帧。本章探讨了 Dask 的 bag 和 array 接口的地方——相对于数据帧而言,这些接口经常被忽视更合适。正如在“Hello Worlds”中提到的,Dask 袋实现了常见的函数式 API,而 Dask 数组实现了 NumPy 数组的一个子集。

提示

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

Dask 数组

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 需要知道块的大小来支持索引;如果一个数组具有未知的块大小,您可以调用此函数。

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

Dask Bags

继续与 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 u

urls = list(map(make_url, range(0, 10)))
# Since they are multi-line json we can't use the default \n line delim
raw_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 DataFrame
df = 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 result

def 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 这样的结构,而不是对同一函数的递归调用。

第六章:高级任务调度:Futures 和 Friends

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 的用例

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

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

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

请求/响应模式

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

IO

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

超时

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

点火并忘记

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

Actors

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

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

启动 Futures

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

Example 6-1. 启动 futures
from dask.distributed import Client
client = Client()

def slow(x):
    time.sleep(3 * x)
    return 3 * x

slow_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 相同。

Future 生命周期

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

示例 6-2. 期货生命周期
myfuture = client.submit(slow, 5) # Starts running
myfuture = None # future may be GCd and then stop since there are no other references

myfuture = client.submit(slow, 5) # Starts running
del myfuture # future may be GCd and then stop since there are no other references

myfuture = client.submit(slow, 5) # Starts running
# Future stops running, any other references point to canceled future
myfuture.cancel()

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

警告

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

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

火而忘之

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

示例 6-3. 火而忘之
from dask.distributed import fire_and_forget

def 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_completed

for 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 wait
from 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 futures
for f in finished.done:
    print(f.result())

# Cancel the futures we don't need
for f in finished.not_done:
    f.cancel()

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

示例 6-8. 获取在时间限制内完成的任何 futures
max_wait = 10
start = 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 futures
for 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 here
time.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 则是在你到达时才开始的。

嵌套 Futures

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

示例 6-10. 启动一个嵌套 future
from dask.distributed import get_client

def 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 r

f = client.submit(nested, 3)
f.result()

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

结论

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

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

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

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