无论你是数据科学家/工程师、机器学习(ML)专家,还是一名试图构建数据分析工具的软件工程师,你很可能已经听说过或阅读过有关 Apache Arrow 的内容,并可能想要了解更多信息,或好奇它究竟是什么。希望本书能够成为你理解 Apache Arrow 的跳板,帮助你了解它是什么以及它不是什么,同时作为一本可以不断参考的指南,帮助你提升数据分析能力。
现在,我们将从介绍 Apache Arrow 是什么及其用途开始。之后,我们将详细讲解 Arrow 的规范,设置开发环境,让你能够使用各种 Apache Arrow 库进行实践,并通过一些简单的练习帮助你熟悉如何使用这些库。
在本章中,我们将涵盖以下主题:
- 理解 Arrow 格式和规范
- 为什么 Arrow 使用列式内存格式?
- 学习术语和物理内存布局
- Arrow 格式的版本控制和稳定性
- 设置你的练习环境
技术要求
在本章关于如何设置开发环境以使用各种 Arrow 库的部分,你将需要以下内容:
-
你首选的集成开发环境(IDE),例如 VS Code、Sublime、Emacs 或 Vim
-
你想要使用的编程语言插件(可选但强烈推荐)
-
你希望使用的编程语言的解释器或工具链:
- Python 3.8+:pip 和 venv 和/或 pipenv
- Go 1.21+
- C++ 编译器(支持编译 C++17 或更新版本)
理解 Arrow 格式和规范
Apache Arrow 文档中指出如下内容【1】:
Apache Arrow 是一个用于内存分析的开发平台。它包含了一系列技术,使大数据系统能够快速处理和传输数据。它规定了一种标准化的、与语言无关的列式内存格式,适用于扁平和层次化数据,组织方式旨在优化现代硬件上的分析操作。
哇,真是一大堆技术术语!让我们从头开始解释。Apache Arrow(简称 Arrow)是 Apache 软件基金会(apache.org)的一个开源项目,遵循 Apache 许可证第 2.0 版发布【2】。它由 Jacques Nadeau 和 pandas 的创始人 Wes McKinney 共同创建,并于 2016 年首次发布。简单来说,Arrow 是一组库和规范,使构建高性能软件工具来处理和传输大型数据集变得更为简单。它由一组与内存数据处理相关的库组成,包括内存布局的规范,以及用于在系统和进程之间共享和高效传输数据的协议。当我们谈论内存数据处理时,我们指的是完全在 RAM 中处理数据,并尽可能消除慢速数据访问(以及重复的数据复制和转换),以提高性能。Arrow 在这方面表现出色,提供了支持数据流和传输的库,以加快数据访问。
在处理数据时,主要需要考虑两种情况,每种情况都有不同的需求:内存格式和磁盘格式。当数据存储在磁盘上时,最大的关注点是数据的大小和将其读入主存储器的输入/输出(I/O)成本,然后你才能对数据进行操作。因此,磁盘上的数据格式往往更关注增加 I/O 吞吐量,例如通过压缩数据使其更小,从而加快读取到内存的速度。Apache Parquet 格式就是一个例子,它是一种列式磁盘文件格式。与磁盘格式不同,Arrow 专注于内存格式,目标是提高处理效率,采用多种策略,如缓存局部性和计算的向量化。
Arrow 的主要目标是成为数据分析和处理的通用语言——所谓的“一统格式”。不同的数据库、编程语言和库往往会实现和使用各自不同的内部数据格式,这意味着每当你在这些组件之间传输数据时,每次都需要付出序列化和反序列化的代价。不仅如此,还需要大量时间和资源来反复在这些不同的数据格式中重新实现常见的算法和处理。如果我们能够标准化一种高效、功能丰富的内部数据格式,并广泛采用,那么这些多余的计算和开发时间就不再是必须的。图 1.1 展示了一个简化的示意图,描述了多个系统各自采用不同的数据格式,因此它们在协同工作时需要复制和/或转换数据。
在许多情况下,序列化和反序列化过程可能会占用系统中近 90% 的处理时间,导致你无法将这些 CPU 资源用于数据分析。相反,如果每个组件都使用 Arrow 的内存格式,你将拥有一个类似于图 1.2 所示的系统,其中数据可以在组件之间几乎无成本地传输。所有组件都可以直接共享内存,或者以原样形式发送数据,无需在不同格式之间进行转换。
到这里,不同组件和系统之间就不再需要实现自定义连接器或重新实现常见的算法和工具。相同的库和连接器可以跨编程语言和进程边界直接共享内存,从而引用相同的数据,而不是多次复制数据。这个概念的例子将在第 8 章《理解 Arrow 数据库连接性(ADBC)》中讨论,届时我们将探讨如何利用通用的数据库驱动程序进行跨平台操作,以便高效处理 Arrow 格式的数据。
如今,大多数数据处理系统都使用分布式处理方式,将数据分块并通过网络发送到不同的工作节点。所以,即使我们可以跨进程共享内存,仍然需要承担将数据通过网络发送的成本。接下来就是问题的最后一部分:传输中使用的原始 Arrow 数据格式与内存中的格式相同。这样你就不必在使用数据之前反序列化它(省去了复制的步骤),或者可以直接引用正在操作的内存缓冲区,将其发送到网络上,而不需要首先序列化它。只需附加一点元数据和执行零拷贝的接口,就可以通过减少内存使用并提高吞吐量来实现性能优化。我们将在第 3 章《格式和内存处理》中更直接地讨论这个问题,敬请期待!
让我们在继续之前快速回顾一下刚刚提到的 Arrow 格式的特性:
- 在各个组件中使用相同的高性能内部格式,可以大量重复使用库中的代码,而无需重新实现常见的工作流程。
- Arrow 库提供了机制,可以直接共享内存缓冲区,减少进程之间的复制,因为它们使用相同的内部表示形式,无论使用哪种语言。这就是“零拷贝”一词的含义。
- 传输格式与内存格式相同,这消除了在系统组件之间通过网络传输数据时的序列化和反序列化成本。
你可能会想:“这听起来太好了,难以置信!” 当然,对这些承诺持怀疑态度是很明智的。多年来,Arrow 社区为实现这些想法和概念做了大量工作。该项目本身提供并分发了多种编程语言的库,因此想要支持 Arrow 格式的项目不需要自己实现它。除了与 Arrow 格式化数据的交互之外,这些库还为常见的过程提供了大量的工具,比如数据访问和 I/O 相关的优化。因此,即使项目本身不使用 Arrow 格式,这些库也可以带来很大帮助。
以下是几个使用 Arrow 作为内部/中间数据格式的示例,这样做可能会非常有利:
- SQL 执行引擎(例如 Dremio Sonar、InfluxDB 或 Apache DataFusion)
- 数据分析工具和管道(例如 pandas 或 Apache Spark)
- 流式处理和消息队列系统(例如 Apache Kafka 或 Storm)
- 存储系统和格式(例如 Apache Parquet、Cassandra 和 Kudu)
至于 Arrow 如何帮助你,这取决于你处理的那一部分数据。以下是一些与数据打交道的不同角色,展示了使用 Arrow 的潜在好处,当然,这并不是一个完整的列表:
-
如果你是数据科学家:
- 你可以通过 Polars 或 pandas 和 NumPy 的集成使用 Arrow,显著提升数据操作的性能。
- 如果你使用的工具支持 Arrow,你可以通过直接使用 Arrow 来减少数据复制和/或序列化成本,从而大大加快查询和计算速度。
-
如果你是专注于提取、转换和加载(ETL)的数据工程师:
- 更广泛地采用 Arrow 作为内部和外部格式,可以更容易地与许多不同的工具集成。
- 通过使用 Arrow,数据可以在进程和工具之间共享,共享内存增加了你构建管道时可用的工具数量,无论你使用的是哪种语言。你可以从 Python 获取数据,在 Spark 中使用它,然后直接传递给 Java 虚拟机(JVM),而无需支付复制的成本。
-
如果你是构建数据分析计算工具和实用程序的软件工程师或机器学习专家:
- 使用 Arrow 作为内部格式可以通过减少组件之间的序列化和反序列化来改进内存使用和性能。
- 理解如何最好地利用数据传输协议可以帮助你并行化查询并访问数据,无论它位于何处。
由于 Arrow 可用于任何类型的表格数据,它可以集成到许多不同的数据分析和计算管道中,作为内部数据格式和数据传输格式都具有足够的灵活性和优势,无论数据的形态如何。
现在你已经了解了 Arrow 是什么,让我们深入探讨它的设计,以及它是如何实现高性能分析、零拷贝共享和无序列化网络通信等承诺的。首先,你会看到为什么为 Arrow 的内部格式选择了面向列的内存表示。在后续章节中,我们还将讨论具体的集成点、明确的示例和传输协议。
为什么 Arrow 使用列式内存格式?
关于数据库应该采用行式还是列式组织方式的讨论往往很多,但这些主要指的是底层存储文件的磁盘格式。Arrow 的数据格式与目前讨论的大多数情况不同,因为它直接在内存中使用了列式的数据结构组织。如果你对“列式”这个术语不熟悉,让我们先看看它的含义。首先,想象一下下面这张数据表:
传统上,如果你要将这张表读入内存,你可能会有某种结构来表示一行数据,并且一次读取一行数据——可能类似于 struct { string archer; string location; int year }。结果是每一行的数据在内存中紧密排列在一起,这对于每次都需要读取所有列或每次写入整行数据来说非常有效。然而,如果这是一个更大的表,并且你只想找出最小和最大的年份,或者进行其他按列分析(例如唯一的地点),你就不得不将整张表读入内存,然后在内存中跳来跳去,跳过你不关心的字段,只为读取某一列的每一行数据。
大多数操作系统在将数据读入主内存和 CPU 缓存时,会试图预测它下一步需要哪些内存。在我们弓箭手的示例表中,考虑一下如果数据是按行或按列组织的,那么为了获取唯一地点的列表,需要访问和遍历多少内存页(有关更多详细信息,请参阅第 3 章):
如图 1.4 所示,列式格式将数据按列而不是按行进行组织。因此,基于列值的操作(如分组、筛选或聚合)变得更加高效,因为整个列已经在内存中连续存储。再考虑内存页的情况,很容易看出,对于一个大型表,从行式缓冲区中获取唯一地点列表需要遍历的内存页比列式缓冲区多得多。更少的页面错误和更多的缓存命中意味着更高的性能和更加高效的 CPU。计算例程和查询引擎往往对数据集的部分列进行操作,而不是每次计算都需要所有列,因此操作列式数据的效率明显更高。
仔细观察图 1.4 右侧列式数据缓冲区的结构,你会发现它如何对前面提到的查询有所帮助。如果我们想找出所有位于欧洲的弓箭手,我们可以轻松地仅扫描地点这一列,找出我们需要的行,然后仅遍历弓箭手这一块,抓取与找到的行索引对应的行数据。当我们开始研究 Arrow 数组的物理内存布局时,这一点将再次显现;由于数据是按列组织的,这使得 CPU 更容易预测要执行的指令,并在指令之间保持内存的局部性。
通过在内存中保持列数据的连续性,计算可以被向量化。大多数现代处理器都有可用的单指令多数据(SIMD)指令,这些指令可以用于加速计算,并要求数据位于连续的内存块中以便进行操作。这个概念在图形处理器中被大量使用,正因为如此,Arrow 提供了库以充分利用图形处理单元(GPU)。例如,你可能想要将列表的每个元素乘以一个固定值,比如使用汇率对价格列进行货币转换:
在图 1.5 中,你可以看到以下内容:
- 图的左侧显示了普通 CPU 以非向量化的方式执行计算的过程,它需要将每个值加载到寄存器中,与汇率相乘,然后将结果保存回 RAM。
- 图的右侧显示了向量化计算的过程,例如使用 SIMD(单指令多数据),它能够同时对多个不同的输入执行相同的操作,从而一次加载便可以对整个价格组进行乘法计算并保存结果。能够向量化计算有各种限制条件,其中一个常见的限制是需要操作的数据位于连续的内存块中,这就是为什么列式数据更容易实现向量化的原因。
SIMD 与多线程
如果你对 SIMD 不熟悉,你可能会想知道它与另一种并行化技术——多线程——有何不同。多线程在概念层次上高于 SIMD。每个线程有自己的一组寄存器和内存空间,表示其执行上下文。这些上下文可以分布在不同的 CPU 核心上,或者在单个 CPU 核心上交替执行,以便在等待 I/O 时切换。SIMD 是一个处理器级的概念,指的是特定的指令执行。简单来说,多线程涉及多任务处理,而 SIMD 则是在执行更少的工作以实现相同的结果。
使用列式数据的另一个好处在于考虑压缩技术时。到某个时候,你的数据会变得足够大,以至于通过网络传输会成为瓶颈,纯粹是因为数据量和带宽的限制。由于数据在列式存储中是按相同类型分组并以连续的内存存储,你最终会获得比行式配置更好的压缩比,这只是因为相同类型的数据比不同类型的数据更容易一起压缩。
学习术语和物理内存布局
如前所述,Arrow 的列式格式规范包括对内存数据结构、元数据序列化和数据传输协议的定义。该格式本身有几个关键的承诺:
- 顺序访问的数据邻接性
- O(1)(常数时间)随机访问
- SIMD 和向量化友好
- 可迁移,允许在共享内存中进行零拷贝访问
为了确保我们有一致的理解,以下是格式规范和本书其余部分中使用的一些术语的简要词汇表:
- Array(数组) :具有已知长度且类型相同的值列表。
- Slot(槽位) :由特定索引标识的数组中的值。
- Buffer/contiguous memory region(缓冲区/连续内存区域) :具有特定长度的单个连续内存块。
- Physical layout(物理布局) :数组的底层内存布局,不考虑逻辑值的解释。例如,32 位有符号整数数组和 32 位浮点数组都布局为连续的内存块,其中每个值由缓冲区中的四个连续字节组成。
- Parent/child arrays(父/子数组) :描述嵌套类型结构时用于物理数组之间关系的术语。例如,结构体父数组的每个字段都有一个子数组。
- Primitive type(基本类型) :没有子类型的类型,因此由单个数组组成,例如固定位宽数组(例如 int32)或可变大小类型(例如字符串数组)。
- Nested type(嵌套类型) :依赖于一个或多个其他子类型的类型。嵌套类型只有在其子类型相同时才相等(例如, 和 只有在 T 和 U 相等时才相等)。
- Data type(数据类型) :解释数组中值的特定方式,使用特定的物理布局实现。例如,小数数据类型将值存储为每个值 16 字节的固定大小二进制布局。同样,时间戳数据类型使用 64 位固定大小布局存储值。
现在我们已经解决了这些专业术语,让我们看看如何在内存中布局这些数组。一个数组或向量由以下信息定义:
- 数据类型(通常由枚举值和元数据标识)
- 一组缓冲区
- 以 64 位有符号整数表示的长度
- 以 64 位有符号整数表示的空值计数
- 可选的字典,用于字典编码的数组(本章稍后会详细介绍)
为了定义嵌套数组类型,还需要一个或多个这些信息集,作为子数组。Arrow 定义了一系列数据类型,每种类型在规范中都有明确的物理布局。在大多数情况下,物理布局仅影响组成原始数据的缓冲区序列。由于元数据中有一个空值计数,因此可以假定数组中的任何值可能是空值而不是实际值,无论其类型如何。除了联合数据类型外,所有数组都包含一个有效性位图作为其缓冲区之一,如果数组中没有空值,则可以省略该位图。如预期的那样,对应位中的 1 表示该索引处为有效值,0 表示该值为空。
物理布局的快速总结
当处理 Arrow 格式的数据时,理解其在内存中的物理布局至关重要。理解这些物理布局可以为在开发应用程序时高效构建(或分解)Arrow 数据提供思路。以下是一个快速总结:
让我们来看看 Arrow 格式使用的物理内存布局。这主要在你自己实现 Arrow 规范(或为库做贡献)时会派上用场,或者如果你只是想了解底层的运行原理和它是如何工作的,这也会对你有所帮助。
固定长度基本值数组
我们来看一个 32 位整数数组的示例,它的内容是:1,null,2,4,81, null, 2, 4, 81,null,2,4,8。基于你到目前为止所掌握的信息,它在内存中的物理布局会是什么样的呢?(见图 1.7)需要记住的是,所有的缓冲区都应该填充到 64 字节的倍数,以实现对齐,这与广泛部署的 x86 架构处理器上可用的最大 SIMD 指令(如 Intel AVX-512)相匹配。对于空值槽位,值被标记为 UNF(未定义)。实现可以自由地将空值槽中的数据清零,很多实现确实会这么做。然而,由于格式规范没有定义任何内容,理论上空值槽中的数据可能是任何值:
这种概念布局同样适用于任何固定大小的基本类型,唯一的例外是当数组中没有空值时,可以完全省略有效性缓冲区。对于任何物理上表示为简单固定位宽的值的数据类型,例如整数、浮点值、固定大小的二进制数组,甚至时间戳,它们在内存中都将使用这种布局。在接下来的图表中,缓冲区的填充将被省略,以避免图表过于杂乱。
可变长度的二进制数组
当处理可变长度的值数组时,情况会变得稍微复杂一些,这些数组通常用于可变大小的二进制或字符串数据。在这种布局中,每个值可以由 0 个或更多字节组成,除了数据缓冲区之外,还会有一个偏移量缓冲区。使用偏移量缓冲区可以将数组的所有数据保存在单个连续的内存缓冲区中。要找到某个索引的值,唯一的查找成本是查找偏移量缓冲区中的索引,以找到数据的正确切片。偏移量缓冲区总是包含长度 + 1 个有符号整数(根据所使用的数据类型,可能是 32 位或 64 位),这些整数指示数组中每个对应槽位的起始位置。考虑一个包含两个字符串的数组:"Water","Rising""Water", "Rising""Water","Rising"。
这种方式与大多数库模型中在内存中表示字符串列表的标准方式有所不同。通常,一个字符串表示为指向内存位置的指针以及一个表示长度的整数,因此字符串的向量实际上是这些指针和长度的向量(见图 1.8)。对于许多用例而言,这种方式非常高效,因为通常一个内存地址会比字符串数据的大小要小得多,因此传递这个地址和长度在引用单个字符串时效率很高。
但如果你的目标是对大量字符串进行操作,那么在内存中使用单个缓冲区进行扫描效率会更高。当你操作每个字符串时,可以保持我们之前提到的内存局部性,使得我们需要查看的内存与我们可能接下来需要的内存块物理上保持接近。这样,我们花费的时间更少在不同的内存页之间跳转,可以将更多的 CPU 周期用于执行计算。此外,获取单个字符串的效率也非常高,因为你可以简单地根据偏移量指示的地址对缓冲区进行视图操作,从而创建字符串对象,而无需复制数据。
可变长度二进制视图数组
Arrow 拥有庞大的社区,随着更多系统兼容性的采用,开发仍在不断演进。慕尼黑工业大学的研究人员撰写了一篇论文,描述了一个他们称为 UmbraDB 的系统。该论文的细节非常有趣,我强烈建议阅读(db.in.tum.de/~freitag/pa…)。然而,这里相关的是,论文提出了一种用于列式数据的新的高效内存字符串表示方法。鉴于其来源,这种表示方式也被广泛称为“德式字符串”(German-style strings)。这种表示方式后来被两个非常流行的开源项目采用:Meta 的 Velox 引擎和 DuckDB。最终,它被改编为 Arrow 数组的数据类型,以保持与这些系统的零拷贝兼容性,并将这种表示方法引入依赖 Arrow 的整个生态系统。
与可变长度二进制数组简单使用偏移缓冲区和数据缓冲区的方式不同,二进制视图更加复杂。像大多数数据类型一样,第一个缓冲区是有效性位图。由于数组中的每个值由 0 或更多字节组成,第二个缓冲区使用固定大小的 16 字节结构来表示每个字节视图的位置和长度(称为视图头)。最后,虽然其他所有数据类型都有固定数量的缓冲区,但二进制视图数据类型是 Arrow 数据类型中首次出现可变数量缓冲区的案例!让我们来看一下!
图 1.10 描绘了数组中每个值对应的二进制视图头结构。根据两种情况之一,字节的解释会略有不同:短字符串(长度 < 12)或长字符串(长度 > 12)。如果你熟悉常见的编译器优化,这种设计灵感来自所谓的小字符串优化或短字符串优化。对于足够小的字符串,我们可以避免使用额外的内存和间接存储访问,而是将字符串内联存储在结构中。如果你处理的是大量的小字符串,这将带来显著的性能提升。
对于较大的字符串值,前四个字节会被作为前缀复制到结构中(存储在长度之后)。内联存储前缀可以加速字符串比较;如果前缀不匹配,就无需查看其余的数据。前缀之后是两个 4 字节的整数,分别指示完整字符串数据所在的缓冲区(在有效性和视图缓冲区之后)以及该字符串在缓冲区中的起始偏移位置。这听起来可能有点复杂,但图 1.11 展示了实际情况,并提供了一个数组的可能布局:"Hello","Pennythecat","andwelcome""Hello", "Penny the cat", "and welcome""Hello","Pennythecat","andwelcome"。
我称其为“可能的”布局的原因在于二进制视图布局允许轻松重用缓冲区。在有效性位图和视图缓冲区之后,可以有任意数量的数据缓冲区,且所指示的偏移量可以位于这些缓冲区的任意位置。
重要提示!
在这个布局中引用的所有整数(长度、缓冲区索引和偏移量)必须是有符号的 32 位整数。原因是某些语言——尤其是 Java——在处理无符号整数时比有符号整数更加困难。因此,为了确保在多语言环境中更易于互操作,Arrow 规范在适用时更倾向于使用有符号整数而非无符号整数。
除了受益于短字符串优化和利用前缀比较外,二进制视图数据类型还有另一个显著的优势:由于视图头是固定大小的结构,并且可以通过索引引用任意缓冲区和偏移量,它可以轻松地乱序并行构建。这使得二进制视图数组非常适合可以轻松并行化的按元素操作,比如构建子字符串、排序或条件操作。
新增功能!
当你使用这个数据类型时,请留意 Arrow 的版本。这个类型是在 Columnar Format v1.4 中添加的,并从 Arrow v14 库开始在多个实现中发布。要了解哪些实现支持这些新数据类型,可以查看文档中的实现状态页面(arrow.apache.org/docs/status…)。
列表和固定大小列表数组
那么嵌套格式呢?它们的工作方式与可变长度二进制格式类似。首先是可变大小列表布局。它由两个缓冲区——有效性位图和偏移量缓冲区——以及一个子数组组成。与可变长度二进制格式的区别在于,偏移量不是引用缓冲区,而是引用子数组的索引(该子数组本身可能是嵌套类型)。列表类型通常表示为 List<T>,其中 T 可以是任何类型。使用 64 位偏移量而不是 32 位时,则表示为 LargeList<T>。让我们来表示以下 List<Int8> 数组:[12,−7,25],null,[0,−127,127,50],[][12, -7, 25], null, [0, -127, 127, 50], [][12,−7,25],null,[0,−127,127,50],[]。
在上图中首先要注意的是,偏移量缓冲区的元素数量比它所属的列表数组多一个元素。由于我们的 List<Int8> 数组有四个元素,因此偏移量缓冲区中有五个元素。偏移量缓冲区中的每个值表示对应列表索引 i 的起始槽位。仔细查看偏移量缓冲区,我们注意到 3 和 7 是重复的,表示这些列表要么是空的,要么为 null(长度为 0)。要确定某个槽位中的列表长度,只需计算该槽位的偏移量与其后一个槽位的偏移量之间的差值即可。
对于之前的可变长度二进制格式,同样适用;某个槽位的字节数是其偏移量的差值。了解这一点后,图 1.12 中索引 2 处的列表长度是多少呢?
记住,索引是从 0 开始的!由此我们可以知道,索引 3 处的列表是空的,因为位图显示为 1,但长度为 0(7 - 7)。这也解释了为什么我们需要在偏移量缓冲区中多出一个元素!我们需要它来计算数组中最后一个元素的长度。
通过这个例子,你能猜出 List<List<Int8>> 数组会是什么样子吗?我留给你作为练习去探索。
还有一种 FixedSizeList<T>[N] 类型,它的工作方式几乎与可变大小列表相同,唯一的区别是它不需要偏移量缓冲区。固定大小列表类型的子数组是值数组,配有它自己的有效性缓冲区。固定大小列表数组中某个槽位的值存储在值数组中长度为 NNN 的切片中,起始位置从偏移量 iii 开始。图 1.13 展示了它的样子:
FixedSizeList 与 List 的优势是什么?再回头看看前面的两个图!确定 FixedSizeList 的某个槽位的值不需要查找单独的偏移量缓冲区,这使得在你知道列表始终是固定大小时效率更高。结果是,你还可以节省空间,因为不需要额外的内存来存储偏移量缓冲区!
重要提示
需要记住的一点是,空值和空列表之间的语义差异。使用 JSON 表示法,这相当于 null 和 [] 之间的区别。具体应用程序可以决定这种差异的意义,但需要注意的是,空列表与空值并不相同,即使它们在物理表示上的唯一区别是有效性位图中的一位。
哇!信息量很大,我们快完成了!
ListView 数组
类似于可变长度二进制数组及其视图对应类型的关系,可变长度列表数组也有一个视图对应类型。ListView<T> 数据类型包含有效性位图、偏移量缓冲区和一个子数组,就像 List<T> 数组一样,但它增加了一个额外的缓冲区来表示列表视图的大小,而不是从偏移量推断列表的大小。大小缓冲区的好处是它允许高效的乱序处理。按照惯例,让我们通过表示以下 ListView<Int8> 数组来看看它的样子:[12,−7,25],null,[0,−127,127,50],[],[50,12][12, -7, 25], null, [0, -127, 127, 50], [], [50, 12][12,−7,25],null,[0,−127,127,50],[],[50,12]。
在图 1.14 中,你可以马上看到,偏移量不像标准的可变长度列表数组布局那样保证是递增的。数组中第一个列表的元素位于子数组的末尾,而第三个列表的元素则位于子数组的开头。
不仅偏移量是无序的,子数组中的值还被多个列表元素共享。最后一个列表元素使用了值 50 和 12,而这两个值也属于另外两个列表。
每个列表视图值(包括 null 和空列表)都必须保证以下条件:
- 0 ≤ offsets[i] ≤ len(child)
- 0 ≤ offsets[i] + sizes[i] ≤ len(child)
此外,与可变长度列表布局一样,列表视图数组也有 32 位和 64 位两种变体,64 位版本表示为 LargeListView<T>。在这种情况下,位宽指的是偏移量和大小值的大小(即 ListView<T> 包含两个 32 位整数的缓冲区,而 LargeListView<T> 则包含两个 64 位整数的缓冲区)。
你可能已经猜到,列表视图布局允许更高效地表示包含重叠元素的列表,同时通过支持乱序创建和处理来简化并行化。唯一的缺点是需要一个额外的缓冲区来表示列表的大小,至少与标准列表表示相比是这样。因此,对于没有定义乱序或不包含重叠列表元素的列表数组,这种表示方式会比使用标准列表数据类型效率低。与可变长度二进制视图数组布局类似,这种布局也是受开源 Velox 和 DuckDB 引擎中使用的表示方法启发的。
新增功能!
当你使用此数据类型时,请留意 Arrow 的版本。此类型是在 Columnar Format v1.4 中添加的,并从 Arrow v14 库开始在多个实现中发布。要了解哪些实现支持这些新数据类型,可以查看文档中的实现状态页面(arrow.apache.org/docs/status…)。
结构体数组
我们接下来要介绍的是 Arrow 格式中的结构体类型的布局。结构体是一种嵌套类型,包含一组有序的字段,这些字段可以具有不同的类型。它在语义上非常类似于你可能在各种编程语言中看到的带属性的简单对象。每个字段必须有自己的 UTF-8 编码名称,这些字段名是定义结构体类型的元数据的一部分。结构体数组不会为其值分配任何物理存储空间,而是为每个字段拥有一个子数组。这些子数组是独立的,彼此不必在内存中相邻;记住,我们的目标是列式(或字段式)存储,而不是行式存储。然而,如果结构体数组包含一个或多个 null 结构体值,则必须有一个有效性位图。如果没有空值,它仍然可以包含有效性位图,但在这种情况下是可选的。
让我们使用一个具有以下结构的结构体作为例子:Struct<name: VarBinary, age: Int32>。这种类型的数组将拥有两个子数组,一个 VarBinary 数组(可变大小的二进制布局)和一个以 Int32 作为数据类型的 4 字节基本值数组。根据这个定义,我们可以映射出数组 [{"joe", 1}, {null, 2}, null, {"mark", 4}] 的表示形式:
当结构体数组的整个槽位被设置为 null 时,空值在父数组的有效性位图中表示,这与子数组中的特定值为 null 不同。在图 1.15 中,子数组各自有一个槽位对应 null 结构体,其中可能包含任何值。然而,这些值会被结构体数组的有效性位图隐藏,因此将相应的结构体槽位标记为 null,并且优先于子数组中的值。
联合数组——稀疏和密集
当单个列可能包含多种类型时,就存在联合类型数组。结构体数组是字段的有序序列,而联合类型是类型的有序序列。数组中每个槽位的值可以是这些类型中的任何一种,这些类型像结构体字段一样被命名,并包含在类型的元数据中。与其他布局不同,联合类型没有自己的有效性位图。相反,每个槽位的有效性由其子数组确定,这些子数组组合在一起形成联合数组。创建数组时可以使用两种不同的联合布局:密集和稀疏。每种布局都针对不同的使用场景进行优化。
密集联合表示一个混合类型数组,每个值有 5 字节的开销。它包含以下结构:
- 每种类型都有一个子数组。
- 类型缓冲区:一个 8 位有符号整数的缓冲区,每个值表示对应槽位的类型 ID,指示从哪个子向量中读取该槽位的值。
- 偏移量缓冲区:一个 32 位有符号整数的缓冲区,指示每个槽位的类型在相应子数组中的偏移量。
密集联合适用于常见的联合体使用场景,其中结构体的字段不重叠:Union<s1: Struct1, s2: Struct2, s3: Struct3……>。以下是 Union<f: float, i: int32> 类型的联合体的布局示例,其值为 [{f=1.2}, null, {f=3.4}, {i=5}]:
稀疏联合与密集联合具有相同的结构,区别在于没有偏移量数组,因为每个子数组的长度与联合数组本身相等。图 1.17 展示了图 1.16 中的相同联合数组,但作为一个稀疏联合数组。这里没有偏移量缓冲区,两个子数组的长度相同——即 4——而不是不同的长度。
尽管稀疏联合比密集联合占用更多的空间,但在特定的使用场景中它有一些优势。特别是,稀疏联合在许多情况下更容易用于向量化表达式的评估,并且一组等长的数组可以被解释为一个联合,因为你只需要定义类型缓冲区。在解释稀疏联合时,只有类型数组中指示的子数组槽位会被考虑;未选中的值会被忽略,且可以是任何内容。
字典编码数组
接下来,我们介绍字典编码数组的布局。如果你的数据中有许多重复的值,那么使用字典编码可以通过将数据值表示为引用字典索引的整数来节省大量空间,而字典通常由唯一值组成。由于字典是任何数组的可选属性,因此任何数组都可以进行字典编码。字典编码数组的布局类似于一个非负整数的基本整数数组,每个整数表示字典中的索引。字典本身是一个单独的数组,具有相应的适当类型的布局。
例如,假设你有以下数组:["foo", "bar", "foo", "bar", null, "baz"]。如果没有字典编码,我们的数组将如下所示:
如果我们添加字典编码,只需要获取唯一值并创建一个索引数组来引用字典数组。常见的情况是使用 int32,但任何整数类型也都可以使用:
对于这个简单的示例,字典编码的吸引力可能不大,但如果数组中有大量重复的值,显然可以显著改善内存使用情况。你甚至可以直接对字典数组执行操作,必要时更新字典,甚至可以替换字典。
根据规范,字典允许包含重复值,甚至是空值。然而,字典编码数组的空值计数由索引的有效性位图决定,而不管字典本身是否包含空值。
运行结束编码数组
另一种可用的编码方式是运行结束编码(REE)数据类型,这是一种常见编码方法——运行长度编码(RLE)的变体。这些编码数据的方法对于包含长序列相同值(称为 "运行")的数据特别有用。REE 数组的内存布局非常简单:
-
与其他数据类型不同,REE 数组本身没有任何缓冲区。
-
有两个子数组:
- run_ends:16、32 或 64 位有符号整数。每个值是一个逻辑数组索引,表示值运行结束的位置。不允许有空值。
- values:在
run_ends数组的对应索引处的值。
这听起来有点复杂,但你知道接下来是什么——另一个简洁的图解!让我们将一个简单的 32 位浮点数组编码为运行结束编码数组:1.0,1.0,1.0,1.0,null,null,2.0,3.0,3.01.0, 1.0, 1.0, 1.0, null, null, 2.0, 3.0, 3.01.0,1.0,1.0,1.0,null,null,2.0,3.0,3.0。
如果你熟悉运行长度编码(RLE),你会注意到它与 Arrow 使用的运行结束编码(REE)之间的显著区别。进行 RLE 时,直接对每个运行的显式长度进行编码。不幸的是,这样做会使得获取单个索引的随机访问变得低效。要找到特定索引的值,你需要从数组的开头开始累加长度,直到总数大于目标索引。这种方式使得 RLE 打破了 Arrow 一直以来的 O(1) 随机访问规则。通过选择使用 REE 而不是 RLE,我们至少将随机访问的成本从 O(N) 降低到 O(log N)。图 1.20 展示了编码数组在子数组中的样子:
run_ends 数组中的每个值都包含到该索引为止的所有长度的累积和。换句话说,它是数组中该特定运行结束的逻辑索引。如果你需要知道某个运行的长度,只需减去相邻两个运行结束值即可计算长度。回到图 1.20,如果你想确定示例数组中索引 5 的值,可以通过二分查找找到第一个大于目标索引的值。这个值是 run_ends 子数组中索引 1 处的值 6。
查看值子数组的有效性位图,你会发现对应的位(索引 1,记住!索引是从 0 开始的!)是 0。
这表明索引 5 处的值是 null。
注意
由于 run_ends 子数组中不能有空值,你可能会好奇为什么物理布局使用了一个完整的子数组,而不是像可变长度列表数组中的偏移量一样使用单个缓冲区。使用子数组可以更容易维护与长度的关联。解码后的数组的完整长度(“逻辑长度”)是父数组的长度;运行结束值的数量(“物理长度”)与子数组相关联。
如果在父数组中使用缓冲区而不是完整的子数组,任何潜在的好处都不足以抵消由于数组长度与缓冲区大小无关而带来的混乱。
对于 REE 数组,有一些必须满足的条件:
-
run_ends数组中的所有值必须是正数并且严格递增:- 由此可知,所有运行的长度至少为 1。
-
正如前面提到的,REE 数组本身没有缓冲区,它的空值计数字段应为 0。
-
空值通过在子数组中拥有对应值为
null的运行来处理。
新增功能!
使用此数据类型时,请留意 Arrow 的版本。此类型是在 Columnar Format v1.4 中添加的,并从 Arrow v14 库开始在多个实现中发布。要了解哪些实现支持这些新数据类型,可以查看文档中的实现状态页面(arrow.apache.org/docs/status…)。
空数组
最后,还有一个布局,但它很简单:空数组。空数组是一种优化的布局,表示全为空值的数组,类型设置为 null;它唯一包含的就是一个长度——没有有效性位图或数据缓冲区。
还有一个补充
为了允许应用程序提供更丰富的类型语义,Arrow 还提供了用户定义的扩展类型,可以通过在模式中指定特定的元数据键值对来利用。应用程序可以使用这些元数据注释现有的 Arrow 数据类型,以便数据能够安全地通过任何兼容 Arrow 的系统,而无需中间系统了解扩展类型。例如,16 字节的 UUID 类型可以通过将固定大小的二进制类型注释为长度为 16 来表示。
如何掌握 Arrow 的语言
我们在描述物理布局时提到了几种不同的数据类型,现在让我们详细介绍一下截至 Arrow 17.0.0 版本所提供的类型,供您参考。通常,这些类型被称为库中数组的数据类型,而不是物理布局类型。这些类型是您在代码中处理 Arrow 数组时通常会遇到的:
-
Null 类型:空物理类型。
-
Boolean:数据以位图表示的基本数组。
-
基本整数类型:一种基本的固定大小数组布局:
Int8、Uint8、Int16、Uint16、Int32、Uint32、Int64和Uint64
-
浮点类型:一种基本的固定大小数组布局:
Float16、Float32(浮点)和Float64(双精度)
-
可变长度二进制类型:一种可变长度的二进制物理布局:
Binary和String(UTF-8)LargeBinary和LargeString(使用 64 位偏移量的可变长度二进制)
-
可变长度二进制视图类型:可变长度二进制视图的物理布局:
BinaryView和StringView(UTF-8)
-
Decimal128 和 Decimal256:128 位和 256 位的固定大小基本数组,带有用于指定值的精度和刻度的元数据。
-
固定大小二进制:一种固定大小的二进制物理布局。
-
时间类型:一种基本的固定大小数组物理布局。
-
日期类型:不带时间信息的日期:
Date32:32 位整数,表示自 Unix 纪元(1970-01-01)以来的天数。Date64:64 位整数,表示自 Unix 纪元(1970-01-01)以来的毫秒数。
-
时间类型:只带时间信息,不附带日期:
Time32:32 位整数,表示自午夜以来经过的秒或毫秒,单位由元数据指定。Time64:64 位整数,表示自午夜以来经过的微秒或纳秒,单位由元数据指定。
-
时间戳:一个 64 位整数,表示自 Unix 纪元以来的时间,不包括闰秒。元数据定义了单位(秒、毫秒、微秒或纳秒),并可选地以字符串形式指定时区。
-
时间间隔类型:以日历为单位表示的绝对时间长度:
YearMonth:以 32 位有符号整数表示的经过的整月数。DayTime:以两个连续的 4 字节有符号整数(总计每值 8 字节)表示的经过的天数和毫秒数。MonthDayNano:将经过的月、天和纳秒存储为连续的 16 字节块。月和天分别存储为两个 32 位整数,自午夜以来的纳秒存储为一个 64 位整数。
-
持续时间:与日历无关的绝对时间长度,以 64 位整数表示,单位由元数据指定(秒、毫秒、微秒或纳秒)。
-
列表和固定大小列表:它们各自的物理布局:
LargeList:使用 64 位偏移量的列表类型。ListView:可变长度列表视图布局。LargeListView:使用 64 位偏移量和大小的ListView。
-
结构体、密集联合和稀疏联合类型:它们各自的物理布局。
-
映射类型:物理上表示为
List<Struct<key: K, value: V>>,其中K和V是映射中键和值的相应类型:- 元数据中包含了键是否排序的指示。
-
字典类型:物理上表示为字典编码的物理布局
Dict<index: I, value: V>,其中I可以是任何整数类型,V可以是任何其他数据类型。 -
运行结束编码:物理上表示为使用
REE<run_ends: E, values: V>运行结束编码布局的类型,其中E可以是Int16、Int32或Int64,V可以是任何数据类型。
每当我们从应用程序或语义角度讨论数组类型时,我们总是使用上述列表中的类型来描述它们。正如你所看到的,这些数据类型使表示平面和层次化数据类型变得非常容易。现在我们已经介绍了物理内存布局,接下来我们将简要讨论 Arrow 格式和库的版本控制与稳定性。
Arrow 格式的版本控制与稳定性
为了确保更新 Arrow 库版本不会破坏应用程序,并保证 Arrow 项目的长期稳定性,项目的每个发布版本都有两个版本号:格式版本和库版本。不同的库实现和版本可能有不同的库版本,但它们始终实现特定的格式版本。从 1.0.0 版本开始,发布版本遵循语义版本控制。
只要两个库的主要格式版本相同,任何新库在读取旧库生成的数据和元数据时都是向后兼容的。格式版本中的次要版本号增加(例如,从 1.0.0 增加到 1.1.0)表明新增了新功能。只要不使用这些新功能(如新的数据类型或物理布局),旧库仍可以读取由新版本库生成的数据和元数据。
就格式和库的长期稳定性而言,只有格式的主要版本增加才会表明与之前的兼容性保证存在问题。Arrow 项目指出,这类情况不应频繁发生,而是例外情况,这类发布将在部署时特别谨慎。事实上,自 Arrow 格式 1.0 版本发布以来的 4 年(写作时),这种情况还没有发生过!因此,使用 Arrow 库和格式时,确保向后和向前兼容是安全且简单的。
会下载库吗?当然!
如前所述,Arrow 项目包含了针对多种编程语言的各种库。这些官方库允许任何人处理 Arrow 数据,而无需自己实现 Arrow 格式,无论使用的平台和编程语言是什么。目前,主要存在两种类型的库:一种是独立实现 Arrow 规范的库,另一种是基于其他实现构建的库。到写作时,Arrow 已有 C++【3】、C#【4】、Go【5】、Java【6】、JavaScript【7】、Julia【8】和 Rust【9】的实现,所有这些都是独立实现的。
除此之外,还有用于 C (Glib)【10】、MATLAB【11】、Python【12】、R【13】和 Ruby【14】的库,这些库都基于 C++ 库构建,而 C++ 库恰恰是开发最活跃的库。正如你所预期的,各种实现的功能和规范的实现程度不同,文档中提供了实现矩阵,展示了各库中实现了哪些功能。随着这些规范的方面和功能在特定库中得到实现,实现矩阵【15】会被更新。
由于存在如此多的实现,你可能会担心它们之间的互操作性。因此,各库版本通过自动化持续集成(CI)任务进行集成测试,以确保它们之间的互操作性。根据编程语言和开发情况,这些库在多种平台上进行了广泛测试,涵盖但不限于以下平台:
- x86/x86-64
- arm64(大端和小端)
- macOS(amd64 和 arm64)
- Windows 32 位和 64 位
- Debian/Ubuntu/Red Hat/CentOS
这些库通过各种相应的包管理方法进行部署,尽可能简化库的获取和下载。因此,Arrow 已被广泛采用,无论你是使用 pandas、NumPy 或 Dask 的数据科学家,还是使用 Apache Spark 或 AirFlow 进行计算和分析的用户。如果你希望获取这些库以便自己尝试,Apache 软件基金会提供了多种下载和获取库的方式。
让我们看看一些提供库的渠道:
- Conda(适用于 Linux、Windows 和 macOS):conda-forge.github.io/
- Homebrew(适用于 macOS):brew.sh/
- MSYS2(用于跨平台的 Windows 开发)
- vcpkg(github.com/Microsoft/v… Conan(适用于 C++)
- CRAN 上的 R 包:cran.r-project.org/
- Julia 的包注册表:github.com/JuliaRegist…
- RubyGems 上的 Ruby 包:rubygems.org/
- NuGet 上的 C# 包:www.nuget.org/packages/Ap…
- 适用于 Debian、Ubuntu、Red Hat 和 CentOS 的 APT 和 Yum 存储库(包括 Amazon Linux)
- Maven Central 上的 Java 工件
- npm 上的 JavaScript 和 TypeScript 包:www.npmjs.com/package/apa…
- Python 的 Pip 包:pypi.org
- Rust 库可在 crates.io 上获取:crates.io/crates/arro…
在开发利用 Arrow 库的项目时,请记住前面几页提到的术语;大多数库使用类似的术语和命名来描述它们的应用编程接口(API)。
设置你的练习环境
到现在为止,你应该对 Arrow 是什么、它在内存中的基本布局以及基本术语有了相当扎实的理解。接下来,让我们设置一个开发环境,供你测试和使用 Arrow。在本书中,我将主要关注我最熟悉的三个库:C++ 库、Python 库和 Go 库。尽管基本概念适用于所有实现,但它们的具体 API 可能会有所不同。因此,凭借你到目前为止获得的知识,即使没有该语言的精确示例,你也应该能够理解你喜欢的语言的文档。
对于 C++、Python 和 Go 这三种语言,在安装 Arrow 库的说明之后,我将通过一些练习来帮助你熟悉如何在这些语言中使用 Arrow 库。
本书所有示例代码(包括练习的解决方案)都可以在本书的 GitHub 仓库中找到:github.com/PacktPublis…。
使用 PyArrow for Python
由于数据科学是 Arrow 的主要目标之一,因此 Python 库往往是开发者最常使用和互动的库。让我们从快速介绍如何设置和使用 PyArrow 库进行开发开始。
大多数现代 IDE 提供了对 Python 的极佳支持插件,因此你可以启动你喜欢的 Python 开发 IDE。我强烈推荐使用 Python 的虚拟环境创建方法之一,例如 pipenv、venv 或 virtualenv,来设置你的环境。在创建虚拟环境之后,安装 PyArrow 通常非常简单,只需使用 pip:
$ pipenv install pyarrow==17.0.0 # 或者
$ mkdir arrow_playground && cd arrow_playground
$ python3 –m venv .venv
$ source .venv/bin/activate
$ pip3 install pyarrow==17.0.0
根据你的设置和平台,pip 可能会尝试在本地构建 PyArrow。你可以使用 --prefer-binary 或 --only-binary 参数告诉 pip 安装预构建的二进制包,而不是从源代码构建:
$ pip3 install pyarrow==17.0.0 --only-binary pyarrow
除了使用 pip 之外,Conda【16】也是数据科学家和工程师常用的工具集,Arrow 项目为 Linux、macOS 和 Windows 提供了 conda-forge【17】上的二进制 Conda 包,适用于 Python 3.8+。你可以通过 Conda 和 conda-forge 进行安装,如下所示:
$ conda create –n arrow_playground
$ conda activate arrow_playground
$ conda install pyarrow=17.0.* -c conda-forge
理解 PyArrow 的基础知识
安装完包后,让我们通过打开 Python 解释器并尝试导入包来确认是否成功安装:
>>> import pyarrow as pa
>>> arr = pa.array([1,2,3,4])
>>> arr
<pyarrow.lib.Int64Array object at 0x0000019C4EC153A8>
[
1,
2,
3,
4
]
这里需要注意的重要部分是高亮的几行代码,我们导入库并创建了一个简单的数组,让库为我们确定类型。它决定使用 Int64 作为数据类型。
现在我们已经成功安装了 PyArrow 库,我们可以创建一个简单的示例脚本来生成一些随机数据并创建一个记录批次:
import pyarrow as pa
import random
NROWS = 8192
NCOLS = 16
data = [pa.array([random.uniform(-2, 2) for x in range(NROWS)]) for _ in range(NCOLS)]
cols = ['c' + str(i) for i in range(NCOLS)]
rb = pa.RecordBatch.from_arrays(data, cols)
print(rb.schema)
print(rb.num_rows)
在这个简单的示例中,发生了以下情况:
- 首先,
random库用于生成我们可以用于数组的大量数据。调用pa.array(values),其中values是一个值的列表,会构造一个数组,库会推断要使用的数据类型。 - 接下来,创建了一组字符串,格式为 'c0'、'c1'、'c2'...,作为列的名称。
- 最后,高亮的行是我们从这些随机数据中构造记录批次的地方。随后的两行代码打印出模式和行数。
这里引入了一个新术语:记录批次!记录批次是与 Arrow 交互时常见的概念,我们将在许多地方看到它。它指的是一组长度相等的数组和一个模式。通常,记录批次是具有相同模式的更大数据集的一个子集。记录批次是操作数据的一个有用的并行化单位,后面的章节中我们将更深入地了解这一点。也就是说,当你思考它时,记录批次非常类似于结构体数组。结构体数组中的每个字段可以对应记录批次中的一列。让我们用之前的弓箭手示例来继续说明:
既然我们讨论的是结构体数组,它将使用结构体的物理布局:一个数组,每个字段都有一个子数组。这意味着要引用索引 i 处的整个结构体,只需从每个子数组中获取索引 i 处的值,就像你在查看记录批次时一样;你可以使用相同的方法获取索引 i 处的语义行(见图 1.21)。
在构建这样的数组时,有几种方法可以在代码中实现。你可以通过同时以基于行的方式构建所有三个子数组来构建结构体数组,或者你可以完全独立地构建各个子数组,然后只需在语义上将它们与列名一起分组为结构体数组。这展示了使用基于列的内存处理这种结构的另一个优势:每一列都可以并行构建,然后在最后汇总,而不需要任何多余的拷贝。以基于行的方式并行化通常是通过将这些记录分组为批次并对这些批次进行并行操作来完成的。使用基于列的方法也可以实现这种并行处理,还提供了基于行的解决方案中不存在的额外并行化途径。
构建结构体数组
以下步骤描述了如何使用 Python 字典从你的数据构建一个结构体数组。然而,注意数据本身可以来自任何地方,例如 JSON 或 CSV 文件:
首先,让我们创建一个弓箭手的字典来表示我们的数据:
archer_list = [{
'archer': 'Legolas',
'location': 'Mirkwood',
'year': 1954,
},{
'archer': 'Oliver',
'location': 'Star City',
'year': 1941,
}, ……]
这个列表中的其余值就是图 1.3 中的值!
然后,我们必须为我们的结构体数组定义一个数据类型:
archer_type = pa.struct([('archer', pa.utf8()),
('location', pa.utf8()),
('year', pa.int16())])
现在,我们可以构建结构体数组本身:
archers = pa.array(archer_list, type=archer_type)
print(archers.type)
print(archers)
数据类型
注意 pa.utf8() 和 pa.int16() 的使用吗?这些是通过数据类型 API 创建的数据类型实例。你可以通过 pa.list_(t1) 指定一个列表,其中 t1 是其他类型,就像我们在这里使用 pa.struct 一样。请参阅文档【18】了解完整的类型列表。
假设你从图 1.3 中提取了数据,输出将如下所示:
struct<archer: string, location: string, year: int16>
-- is_valid: all not null
-- child 0 type: string
[
"Legolas",
"Oliver",
"Merida",
"Lara",
"Artemis"
]
-- child 1 type: string
[
"Mirkwood",
"Star City",
"Scotland",
"London",
"Greece"
]
-- child 2 type: int16
[
1954,
1941,
2012,
1996,
-600
]
你是否注意到打印的结构体数据与我们之前的列式数据示例之间的相似性?
使用记录批次和零拷贝操作
通常,在获取一些数据后,仍然需要进一步清理或重新组织数据,然后再运行你所需的处理或分析。能够像这样使用 Arrow 重新组织和移动数据结构,而无需进行拷贝,与其他方法相比,显著提升了性能。为了展示如何在使用 Arrow 时优化内存使用,我们可以将刚创建的结构体数组中的数组轻松地扁平化为一个记录批次,而无需进行任何拷贝。让我们将弓箭手的结构体数组扁平化为记录批次:
# archers 是之前创建的结构体数组,flatten() 返回
# 结构体数组的字段作为 python 数组对象列表
# 记住 'pa' 来自于 import pyarrow as pa
rb = pa.RecordBatch.from_arrays(archers.flatten(), ['archer', 'location', 'year'])
print(rb)
print(rb.num_rows) # 输出 5
print(rb.num_columns) # 输出 3
由于我们的结构体数组有 3 个字段,并且长度为 5,所以我们的记录批次将有 5 行和 3 列。记录批次必须定义一个模式,类似于定义一个结构体类型;它是字段的列表,每个字段都有一个名称、数据类型和元数据。上面代码中高亮的打印语句会打印记录批次的模式:
pyarrow.RecordBatch
archer: string
location: string
year: int16
我们创建的记录批次引用了我们为结构体数组创建的相同数组,而不是副本,这使得该操作非常高效,即使对于非常大的数据集也是如此。将原始数据清理、重构并转换为更易理解或处理的格式,是数据科学家和工程师常见的任务。使用 Arrow 的优势之一是可以高效地完成这些任务,而无需拷贝数据。
在处理数据时,另一种常见情况是你只需要处理数据集的某个片段,而不是整个数据集。如前所述,库提供了一个 slice 函数,用于在不复制内存的情况下对记录批次或数组进行切片。回想一下数组的结构;因为任何数组都有长度、空值计数和缓冲区序列,所以给定数组使用的缓冲区可以是来自更大数组的缓冲区切片。这允许你在不复制数据的情况下处理数据的子集:
一个记录批次的切片会对组成该记录批次的每个数组进行切片;对于任何嵌套类型的数组也是如此。使用我们之前的示例,可以使用以下代码:
slice = rb.slice(1, 3) # (起始索引,长度)
print(slice.num_rows) # 输出 3 而不是 5
print(rb.column(0)[0]) # <pyarrow.StringScalar: 'Legolas'>
print(slice.column(0)[0]) # <pyarrow.StringScalar: 'Oliver'>
数组切片也有快捷语法。这对 Python 开发者来说应该很熟悉,因为它与 Python 列表的切片语法相同:
archerslice = archers[1:3] # 长度为 2 的切片,查看
# 结构体数组中索引 1 和 2 的值,因此它切片
# 所有三个数组
有一种情况会创建副本,那就是当你将 Arrow 数组转换回可以与其他不使用 Arrow 的 Python 代码一起使用的本地 Python 对象时。正如我在本章开头提到的,转换和复制数据会有开销,因为不同库之间在格式上不一致:
print(rb.to_pydict())
# 输出字典 {列名: 列表<值>}
print(archers.to_pylist())
# 输出与我们最初开始时相同的字典列表
上述两个调用 to_pylist 和 to_pydict 都会复制数据,以便将它们放入本地 Python 对象格式中,因此对于大数据集应尽量少用。
处理 None 值
最后需要提到的是处理空值。Python 对象 None 在转换为数组时始终会被转换为 Arrow 的空元素,反之亦然,当你将它转换回本地 Python 对象时也如此。
一个练习
为了让你更好地了解库的实际使用情况,以下是一个练习供你尝试。你可以从以下结构的对象列表(按行组织)开始,并将它们转换为按列组织的记录批次:
{ id: int, cost: double, cost_components: list<double> }
例如,单个对象可能是 { "id": 4, "cost": 241.21, "cost_components": [100.00, 140.10, 1.11] }。
现在,你已经将列表中的行数据转换为按列组织的 Arrow 记录批次,接下来再将记录批次转换回按行组织的列表表示形式。
现在,让我们看看 C++ 库。
面向精英程序员的 C++
由于 C++ 的特性,设置过程可能不像 Python 或 Go 那么简单。根据你使用的平台,有几种不同的方法可以安装开发头文件和库,以及必要的依赖项。
使用 C++ 的技术要求
在我们开始开发之前,你需要在系统上安装 Arrow 库。安装过程将根据你使用的操作系统而有所不同:
-
如果你使用的是 Windows,除了 Visual Studio、C++ 或 Mingw gcc/g++ 作为编译器之外,你还需要以下选项之一:
-
Conda:将 17.0.0 替换为你想安装的版本:
conda install libarrow-all=17.0.0 -c conda-forge -
MSYS2【19】:安装 MSYS2 后,你可以使用 pacman 安装必要的库:
-
64 位:
pacman -S --noconfirm mingw-w64-x86_64-arrow -
32 位:
pacman -S --noconfirm mingw-w64-i686-arrow
-
-
vcpkg:这是由 Microsoft 团队成员和社区贡献者维护的:
git clone https://github.com/Microsoft/vcpkg.git cd vcpkg ./bootstrap-vcpkg.sh ./vcpkg integrate install ./vcpkg install arrow -
你也可以自己从源代码构建:arrow.apache.org/docs/develo…
无论你选择哪种方式安装库,你都需要将安装库的路径添加到环境路径中,以便在运行时找到它们。
-
-
如果你使用的是 macOS,并且不想自己从源代码构建库,你可以使用 Homebrew【20】安装该库(注意,这将始终安装 Arrow 的最新版本):
brew install apache-arrow -
如果你使用的是 Linux 并且不想从源代码构建,可以通过 APT 和 Yum 仓库为 Debian GNU/Linux、Ubuntu、CentOS、Red Hat Enterprise Linux 和 Amazon Linux 提供软件包。
所有 C++ 库的安装说明都可以在 arrow.apache.org/install/ 找到。本章中的练习只需要
libarrow-dev。
一旦你设置并配置了环境,我们就可以看看代码了。编译时,最简单的方法是使用 pkg-config(如果系统中有它);否则,请确保你已添加正确的包含路径,并使用适当的选项链接 Arrow 库(-I<arrow 头文件路径> -L<arrow 库路径> -larrow)。
和 Python 示例一样,让我们从一个非常简单的示例开始,以便我们可以了解库的 API。
理解 C++ 库的基础知识
让我们在 C++ 中实现与 Python 中相同的第一个示例:
#include <arrow/api.h>
#include <iostream>
int main(int argc, char** argv) {
std::vector<int64_t> data{1, 2, 3, 4};
auto arr = std::make_shared<arrow::Int64Array>(
data.size(), arrow::Buffer::Wrap(data));
std::cout << arr->ToString() << std::endl;
return 0;
}
与 Python 示例一样,输出如下:
[ 1, 2, 3, 4,]
让我们分解代码中的关键行并解释我们所做的事情。
创建 std::vector<int64_t> 用作示例后,我们通过指定数组的长度(即值的数量)并将向量的原始连续内存包装在缓冲区中来初始化 std::shared_ptr 到 Int64Array,用于该数组的值缓冲区。重要的是,使用 Buffer::Wrap 不会复制数据;我们只是引用向量使用的内存,并将相同的内存块用于数组。最后,我们使用数组的 ToString 方法创建一个字符串表示,然后将其输出。这非常简单,但对于熟悉库并确认环境已正确设置非常有用。
当使用 C++ 库时,通常使用 Builder 模式来高效构建数组。我们可以在 C++ 中做与之前使用 Python 时相同的随机数据示例,尽管代码更详细。我们可以使用标准库的正态分布生成器,而不是 NumPy:
#include <random>
// 稍后使用
std::random_device rd{};
std::mt19937 gen{rd()};
std::normal_distribution<> d{5, 2};
一旦完成了这些设置,我们可以使用 d(gen) 生成随机 64 位浮点数(或双精度值)。接下来,只需将它们输入到一个 builder 中,生成数组并创建一个模式,因为要创建一个记录批次,需要提供一个模式。
再次构建结构体数组
在 C++ 中构建嵌套类型数组时,使用 builder 模式会稍微复杂一些。我们可以实现与 Python 中类似的弓箭手结构体示例!
一个练习
尝试完成与 Python 部分相同的练习,但使用 C++,将 std::vector<row> 转换为 Arrow 记录批次,其中 row 定义如下:
struct row {
int64_t id;
double cost;
std::vector<double> cost_components;
};
然后,编写一个函数,将记录批次转换回以行组织的 std::vector<row> 表示形式。
Go,Arrow,行动吧!
Golang 的 Arrow 库是我直接参与开发的库之一,它和 PyArrow 一样,安装和使用都非常简单。大多数 IDE 都会提供用于 Go 开发的插件。因此,你可以设置你喜欢的 IDE 和环境来编写代码,之后你可以使用以下命令下载 Arrow 库以便导入:
$ mkdir arrow_chapter1 && cd arrow_chapter1
$ go mod init arrow_chapter1
$ go get -u github.com/apache/arrow/go/v17/arrow@latest
提示
如果你对 Go 不熟悉,Tour of Go 是一个出色的语言介绍,可以在这里找到:tour.golang.org/。
到此为止,我想你大概能猜到我们的第一个示例是什么了;只需在你创建的目录中创建一个 .go 扩展名的文件:
package main
import (
"fmt"
"github.com/apache/arrow/go/v17/arrow/array"
"github.com/apache/arrow/go/v17/arrow/memory"
)
func main() {
bldr := array.NewInt64Builder(memory.DefaultAllocator)
defer bldr.Release()
bldr.AppendValues([]int64{1, 2, 3, 4}, nil)
arr := bldr.NewArray()
defer arr.Release()
fmt.Println(arr)
}
正如我们在 C++ 和 Python 库中开始的那样,这是一个最小的 Go 文件,创建了一个包含 [1, 2, 3, 4] 的 Int64 数组并将其输出到终端。Go 库使用了与 C++ 库相同的 builder 模式,它们之间的主要区别在于高亮的几行。你可以使用 go run 命令运行该示例:
$ go run .
[1, 2, 3, 4]
由于 Go 是垃圾回收语言,因此我们对何时清理值或释放内存没有直接控制。虽然在 C++ 中我们有 shared_ptr 和 unique_ptr 对象,但在 Go 中没有等价的结构。为了提供更细粒度的控制,库在大多数结构(如数组)上添加了 Retain 和 Release 函数调用,用于执行引用计数。Retain 确保底层数据保持活跃,特别是在通过通道传递数据时,防止内存被垃圾回收;Release 则释放对内存的引用,使其比数组对象本身更早被垃圾回收。如果你不熟悉 defer 关键字,它会在包含的函数结束之前执行标记的函数调用,执行顺序与代码中出现的顺序相反,类似于 C++ 析构函数。
构建结构体数组,再次!
在 Go 中构建嵌套类型数组比在 Python 中更接近 C++ 库的做法,但在创建结构体类型、填充每个组成数组并完成构建时,仍需遵循类似的步骤。
首先,我们创建我们的类型:
archerType := arrow.StructOf(
arrow.Field{Name: "archer", Type: arrow.BinaryTypes.String},
arrow.Field{Name: "location", Type: arrow.BinaryTypes.String},
arrow.Field{Name: "year", Type: arrow.PrimitiveTypes.Int16})
正如之前所做的,有两种方法来实现这一点:
- 分别构建每个组成数组,然后将它们的引用连接成一个结构体数组:
- 直接使用结构体构建器并同时构建所有组成数组。
一个练习(是的,和之前一样)
尝试使用 Go 的 Arrow 库编写一个函数,将行组织的结构体切片转换为 Arrow 记录批次,反之亦然。使用以下类型定义:
type datarow struct {
id int64
cost float64
costComponents []float64
}
如果你已经在 Python 和 C++ 库中完成了这个练习,那么现在你应该对此相当熟练了!
总结
本章的目标是解释什么是 Apache Arrow,让你熟悉它的格式,并在一些简单的使用案例中使用它。这些知识为我们接下来讨论的内容奠定了基础!
如需查看练习的解决方案以及完整的代码示例,以确保你理解这些概念,请查看本书的 GitHub 仓库:github.com/PacktPublis…。
在本章中提供的示例和练习都相对简单,旨在帮助巩固关于 Arrow 格式和规范的概念,同时让你熟悉如何在代码中使用 Arrow。
在第 2 章《与关键 Arrow 规范一起工作》中,你将学习如何将数据读取到 Arrow 格式中,无论它位于本地磁盘、Hadoop 分布式文件系统 (HDFS)、S3 还是其他地方,并将 Arrow 集成到你可能已经在数据处理中使用的各种工具中,例如 pandas 集成。你还将学习如何在保持 Arrow 格式的同时在服务和进程之间传递数据,以提高性能。