Python Dask 扩展指南(早期发布)(三)
原文:
annas-archive.org/md5/51ecaf36908acb7901fbeb7d885469d8译者:飞龙
附录 A. Dask 用户的关键系统概念
在本书中,我们根据需要简要介绍了一些分布式系统概念,但是当你准备独立进行工作时,复习一些 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 的假设不同)。
机器学习模型在不影响用户的情况下更难验证,但统计技术仍然可以帮助(增量部署也可以)。由于机器学习模型是由数据生成的,验证数据是一个良好(部分)的步骤。
想想你的管道失败可能会带来什么影响是有用的。例如,你可能希望花更多时间验证一个管道,该管道决定临床试验中药物剂量,而不是预测哪个广告版本最成功[³]。
Peer-to-Peer Versus Centralized Distributed
即使在分布式系统内部,也存在各种级别的“分布式”。Dask 是一个集中式分布式系统,其中有一个静态领导节点负责各种任务和协调工作人员之间的工作。在更分布式的系统中,没有静态领导节点,如果主节点消失,剩余的对等节点可以选举一个新的主节点,就像使用 ZooKeeper 一样。在更分布式的系统中,没有主节点的区别,集群中的所有节点在软件上(硬件可能不同)都是同等能力的。
集中式分布式系统倾向于更快,但在扩展方面遇到早期限制,并且在集中组件失败的挑战方面也有所挑战。
并行方法
有很多不同的方法来分解我们的工作,在本书中,我们主要讨论了任务并行和数据并行。
任务并行
dask.delayed 和 Python 的多进程都代表了任务并行。通过任务并行,您不受限于执行相同的代码。任务并行提供了最大的灵活性,但需要更多的代码更改来充分利用它。
数据并行
数据并行指的是对不同数据块(或分区)上的相同操作进行并行运行。这是一种在 DataFrame 和数组上操作的优秀技术。数据并行依赖于分区来分割工作。我们在第四章详细介绍了分区。
洗牌和窄与宽转换
窄 转换(或没有任何聚合或洗牌的数据并行)通常比宽 转换快得多,后者涉及洗牌或聚合。虽然这个术语借用自 Spark 社区,但区分(及其对容错性的影响)同样适用于 Dask 的数据并行操作。
限制
数据并行不太适合各种不同类型的工作。即使在处理数据问题时,也不适合执行多种不同的操作(非均匀计算)。数据并行通常不适合处理少量数据的计算,例如模型服务,可能需要逐个评估单个请求。
负载均衡
负载均衡是并行性的另一种视角,系统(或系统)将请求(或任务)路由到不同的服务器。负载均衡的范围从基本的轮询到“智能”负载均衡,利用关于相对负载、资源和工作服务器/服务器上数据的信息来调度任务。负载均衡越复杂,负载平衡器的工作量就越大。在 Dask 中,所有这些负载均衡都由中心处理,这要求主节点相对完整地查看大多数工作节点的状态以智能地分配任务。
另一个极端是“简单”的负载均衡,例如某些系统,如基于 DNS 轮询的负载均衡(Dask 未使用),没有任何关于系统负载的信息,只是选择“下一个”节点。当任务(或请求)在复杂性上大致相等时,基于轮询的负载均衡可以很好地工作。这种技术最常用于处理 Web 请求或外部 API 请求,其中您无法完全控制进行请求的客户端。您最有可能在模型服务中看到这一点,例如翻译文本或预测欺诈交易。
网络容错和 CAP 定理
如果您搜索“分布式计算概念”,您可能会遇到 CAP 定理。CAP 定理对于分布式数据存储最为相关,但无论如何理解它都是有用的。该定理指出,我们无法构建一个既一致(Consistent)、可用(Available)、又分区容忍(Partition-tolerant)的分布式系统。分区可能由硬件故障或更常见的是由于过载的网络链路引起。
Dask 本身已经做出了不支持容错分区的折衷;网络分区的任一一侧拥有“领导者”,则该侧将继续运行,而另一侧则无法进展。
了解这如何应用于您从 Dask 访问的资源是很重要的。例如,您可能会发现自己处于一种情况中,即网络分区意味着 Dask 无法写入其输出。或者更糟糕的是,在我们看来,它可能导致您从 Dask 存储的数据被丢弃。⁴
由 Kyle Kingsbury 创建的Jepsen 项目是我们所知的用于测试分布式存储和查询系统的最佳项目之一。
递归(尾递归和其他)
递归是指调用自身的函数(直接或间接)。当它是间接的时候,被称为co-recursion,而返回最终值的递归函数被称为tail-recursive。⁵ 尾递归函数类似于循环,有时语言可以将尾递归调用转换为循环或映射。
有时会避免在无法优化递归的语言中使用递归函数,因为调用函数会有开销。相反,用户会尝试使用循环表达递归逻辑。
过度的非优化递归可能导致堆栈溢出错误。在 C、Java、C++等语言中,堆栈内存与主内存(也称为堆内存)分开分配。在 Python 中,递归的数量由setrecursionlimit控制。Python 提供了一个tail-recursive annotation,您可以使用它来帮助优化这些递归调用。
在 Dask 中,虽然递归调用没有完全相同的堆栈问题,但过度递归可能是头节点负载的原因之一。这是因为调度递归调用必须经过头节点,并且过多的递归函数会导致 Dask 的调度器在遇到任何堆栈大小问题之前变慢。
版本控制和分支:代码和数据
版本控制是一个重要的计算机科学概念,它可以应用于代码和数据。理想情况下,版本控制使得很容易撤销错误并返回到早期版本或同时探索多个方向。我们生产的许多物品都是我们的代码和数据的结合;为了真正实现快速回滚和支持实验的目标,您将希望对代码和数据都进行版本控制。
源代码的版本控制工具已经存在很长时间。对于代码来说,Git已经成为使用最广泛的开源版本控制系统,超过了诸如 Subversion、Concurrent Version Systems 等工具。
尽管深入理解 Git 可能非常复杂,⁶ 但对于常见用法,有几个 核心命令 经常能帮助你解决问题。本附录不涵盖 Git 的教学内容,但有许多资源可供参考,包括 Raju Gandhi(O'Reilly)的 Head First Git 和 Julia Evans 的 Oh Shit, 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 资源。
⁹ 我们在这里选择了三个,因为没有驱动程序的工作节点失败的概率仅为驱动程序的两倍(我们无法恢复),并且随着添加更多机器,这种比例呈线性增长。
¹⁰ 您可以缓存中间步骤以减少重新计算的成本,但前提是缓存位置未失败,并且需要清理任何缓存。
¹¹ 精确的性能数字取决于您的硬件。
附录 B. 可扩展数据框架:比较和一些历史
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 问题 外,没有其他文档。
附录 C. 调试 Dask
根据您的调试技术,转向分布式系统可能需要一套新的技术。虽然您可以在远程模式下使用调试器,但通常需要更多的设置工作。您还可以在本地运行 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 在本地重新运行失败的任务,尽管这并非完美解决方案。
使用 Dask 的一般调试技巧
您可能有自己的标准 Python 代码调试技术,并且这些技术并非替代它们。一些在 Dask 中有帮助的一般技术包括以下内容:
-
将失败的函数分解为更小的函数;更小的函数使问题更容易隔离。
-
要小心引用函数外的变量,可能会导致意外的作用域捕获,序列化更多的数据和对象。
-
抽样数据并尝试在本地复现(本地调试通常更容易)。
-
使用 mypy 进行类型检查。虽然我们的示例中未包含类型信息以节省空间,但在生产代码中,宽松的类型使用可以捕捉到棘手的错误。
-
难以追踪任务的调度位置?Dask actors 无法移动,因此可以使用 actor 将所有调用保持在一台机器上进行调试。
-
当问题出现时,无论并行化如何,通过在本地单线程模式下调试您的代码,可以更容易地理解正在发生的事情。
使用这些提示,通常可以在熟悉的环境中找到自己,以使用传统的调试工具,但某些类型的错误可能会更复杂一些。
本地错误
由于容器错误的原因相同,本地错误和核心转储可能很难调试。由于这些类型的错误通常导致容器退出,因此访问调试信息可能变得困难。根据您的部署方式,可能有一个集中式日志聚合器,收集来自容器的所有日志,尽管有时这些日志可能会错过最后几部分(这些部分很可能是您最关心的)。这个问题的一个快速解决方案是在启动脚本中添加一个 sleep(在失败时),以便您可以连接到容器(例如,[dasklaunchcommand] || sleep 100000)并使用本地调试工具。
然而,访问容器的内部可能并不像说起来那么容易。在许多生产环境中,出于安全原因,您可能无法远程访问(例如,在 Kubernetes 上使用 kubectl exec)。如果是这种情况,您可以(有时)向容器规范添加一个关闭脚本,将核心文件复制到容器关闭后仍然存在的位置(例如,s3、HDFS 或 NFS)。您的集群管理员可能还推荐了一些工具来帮助调试(如果没有的话,他们可能能够为您的组织创建一个推荐的路径)。
处理坏记录的官方建议的一些注释
Dask 的 官方调试指南 建议手动移除失败的 futures。当加载可以分块处理而不是一次加载整个分区的数据时,返回具有成功和失败数据的元组更好,因为移除整个分区不利于确定根本原因。这种技术在 示例 C-1 中有所示。
示例 C-1. 处理错误数据的替代方法
# Handling some potentially bad data; this assumes line-by-line
raw_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 诊断
Dask 为 distributed 和 local 调度器都内置了诊断工具。本地诊断工具具有更丰富的功能,几乎涵盖了所有调试的部分。这些诊断工具在您看到性能逐渐下降的调试情况下尤其有用。
注意
在创建 Dask 客户端时,很容易因错误而意外地使用 Dask 的分布式本地后端,因此,如果您没有看到您期望的诊断结果,请确保明确指定您正在运行的后端。
结论
在 Dask 中启动调试工具会需要更多工作,而且在可能的情况下,Dask 的本地模式提供了远程调试的绝佳替代方案。并非所有错误都一样,而且一些错误,比如本地代码中的分段错误,尤其难以调试。祝你找到(们)bug 的好运;我们相信你。
附录 D. 使用 Streamz 和 Dask 进行流式处理
本书专注于使用 Dask 构建批处理应用程序,其中数据是从用户收集或提供的,并用于计算。 另一组重要的用例是需要在数据变得可用时处理数据的情况。¹ 处理数据变得可用时称为流式处理。
由于人们对其数据驱动产品有更高期望,流数据管道和分析变得越来越受欢迎。 想象一下,如果银行交易需要数周才能完成,那将显得极其缓慢。 或者,如果您在社交媒体上阻止某人,您期望该阻止立即生效。 虽然 Dask 擅长交互式分析,但我们认为它(目前)并不擅长对用户查询做出即时响应。²
流式作业与批处理作业在许多重要方面有所不同。 它们往往需要更快的处理时间,并且这些作业本身通常没有定义的终点(除非公司或服务被关闭)。 小批处理作业可能无法胜任的一种情况包括动态广告(几十到几百毫秒)。 许多其他数据问题可能会模糊界限,例如推荐系统,其中您希望根据用户互动更新它们,但是几分钟的延迟可能(主要)是可以接受的。
如 第八章 中所讨论的,Dask 的流式组件似乎比其他组件使用频率低。 在 Dask 中,流式处理在一定程度上是事后添加的,³ 在某些场合和时间,您可能会注意到这一点。 当加载和写入数据时,这一点最为明显 —— 一切都必须通过主客户端程序移动,然后分散或收集。
警告
目前,Streamz 无法处理比客户端计算机内存中可以容纳的更多数据。
在本附录中,您将学习 Dask 流处理的基本设计,其限制以及与其他一些流式系统的比较。
注意
截至本文撰写时,Streamz 在许多地方尚未实现 ipython_display,这可能导致在 Jupyter 中出现类似错误的消息。 您可以忽略这些消息(它会回退到 repr)。
在 Dask 上开始使用 Streamz
安装 Streamz 很简单。 它可以从 PyPI 获得,并且您可以使用 pip 安装它,尽管像所有库一样,您必须在所有工作节点上可用。 安装完 Streamz 后,您只需创建一个 Dask 客户端(即使是在本地模式下),然后导入它,如 示例 D-1 所示。
示例 D-1. 开始使用 Streamz
import dask
import dask.dataframe as dd
from streamz import Stream
from dask.distributed import Client
client = 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 DaskStream
local_wc_stream.sink(lambda x: print(f"WC {x}"))
# Start processing the stream now that we've defined our sinks
batched_kafka_stream.start()
在前述示例中,您可以看到 Streamz 的一些当前限制,以及一些熟悉的概念(如 map)。如果您有兴趣了解更多,请参阅 Streamz API 文档;然而,请注意,根据我们的经验,某些组件在非本地流上可能会随机失效。
在 Dask 流式处理中的 GPU 管道
如果您正在使用 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 的流处理集成性更低。