我不想显得像在重复老调重弹,但我已经多次提到,Apache Arrow 是一组库的集合,而不是一个单一的库。这在技术和后勤方面都有重要的区别。从技术角度来看,这意味着依赖于 Arrow 的第三方项目不需要使用整个项目,而可以只链接、嵌入或包含项目中它们需要的部分。这有助于生成更小的二进制文件,并减少依赖的范围。从后勤角度来看,这使得 Arrow 项目可以更轻松地进行调整,潜在地探索实验性的方向,而无需进行大规模的项目变动。
Arrow 项目的目标是创建一组工具和库,能够在数据分析和数据科学生态系统中共享,并具有共同的内存表示方式。因此,Arrow 在多个领域都可以发挥作用。我们已经讨论了用于读取和写入数据文件的工具(第 2 章:处理关键的 Arrow 规范),但本章将特别涵盖另一个领域——在同一进程中不同语言和运行时之间高效共享数据:
- Arrow C 数据接口用于共享模式和数据
- Arrow C 数据接口用于流式传输数据批次
- 该接口有利的使用场景
技术需求
本章高度技术性,包含各种代码示例和练习,深入探讨不同 Arrow 库的使用。因此,和之前一样,你需要访问一台安装了以下软件的计算机来跟随学习:
- Python 3.8 及以上版本,已安装并可导入
pyarrow模块 - Go 1.21 及以上版本
- 支持 C++17 或更高版本的 C++ 编译器
- 你喜欢的 IDE——Sublime、Visual Studio Code、Emacs 等
- Nvidia 显卡及 CUDA 运行时(可选)
使用 Arrow C 数据接口
在第 2 章《处理关键的 Arrow 规范》中,我提到了使用 Arrow C 数据接口在 Python 和 Spark 进程之间传输数据。当时我们并没有深入探讨这个接口的具体细节,现在我们将详细介绍它。
由于 Arrow 项目发展迅速且不断演变,其他项目有时可能很难将 Arrow 库集成到它们的工作中。此外,可能会有大量现有代码需要逐步适配 Arrow,从而导致你不得不为数据交换创建或重新实现适配器。为避免在这些情况下重复劳动,Arrow 项目定义了一组小而稳定的 C 语言定义,可以直接复制到项目中,从而轻松地在不同语言和库的边界之间传递数据。对于不是 C 或 C++ 的语言和运行时,仍然可以使用与这些语言或运行时相对应的外部函数接口(FFI)声明。
这种方法的优点非常明显:
- 开发人员可以选择将其工具与 Arrow 项目及其所有功能库紧密集成,或仅实现对 Arrow 格式的最小集成。
- 提供了一个稳定的应用程序二进制接口(ABI),在同一进程中的不同运行时和组件之间实现零拷贝的数据共享。
- 允许在不需要显式的编译时或运行时依赖整个 Arrow 库的情况下进行集成。
- 生成的二进制文件和分发库的大小显著减少。
- 避免为特定情况(如 JPype——Java 和 Python 的桥梁)创建单独的适配器,转而使用通用方法,任何人都可以轻松实现支持,投入很少。
需要记住的一点是,C 接口并不是为了提供 Java、C++ 或 Go 等语言中高级操作的 C API,它只是用于传递 Arrow 数据本身。此外,该接口用于在同一进程的不同组件之间共享数据——如果你在不同的进程之间传递数据或尝试存储数据,应该使用 IPC 格式(我们在第 3 章《格式与内存处理》中已经讨论过)。
IPC 与 C 数据格式的比较
如果我们比较 IPC 和 C 数据格式,会发现一些重要的考虑因素。它们都不是通用答案,它们在特定的用例中各有优势。
C 数据接口优于 IPC 格式的原因如下:
- 设计上实现了零拷贝。
- 提供可自定义的资源生命周期管理回调。
- 精简的 C 定义,可以轻松复制到不同的代码库中。
- 数据以 Arrow 的逻辑格式暴露,无需依赖 FlatBuffers。
IPC 格式优于 C 数据接口的原因如下:
- 允许在不同进程和机器之间进行存储和持久化或通信。
- 不需要 C 数据访问。
- 由于它是可流式传输的格式,可以添加其他功能,如压缩。
结构定义和工作原理
现在我们已经解释了 C 数据接口存在的原因,接下来让我们深入了解结构定义及其工作原理。头文件中公开了几个结构——ArrowSchema、ArrowArray、ArrowDeviceArray、ArrowArrayStream 和 ArrowDeviceArrayStream。在撰写本文时,以下 Arrow 语言库已经提供了使用 C 数据接口导出和导入 Arrow 数据的功能:C++、Python、R、Rust、Go、Java、Ruby、C/Glib 和 C#。
我们首先深入了解 ArrowSchema 结构。
ArrowSchema 结构
在频繁处理数据时,数据类型或模式通常由 API 固定,或一个模式适用于多个不同大小的数据批次。通过将模式描述与数据本身分为两个独立的结构,ABI 允许 Arrow C 数据的生产者避免为每个数据批次导出和导入模式的开销。在仅有一个数据批次的情况下,同一 API 调用可以同时返回两个结构,从而在两种情况下都提高效率。
有趣的小知识
尽管名称是 ArrowSchema,但实际上它更类似于 C++ 库中的 arrow::Field 类,而非 arrow::Schema 类。C++ 的 Schema 类表示字段的集合(包含名称、数据类型和潜在的元数据),以及可选的模式级元数据。而 ArrowSchema 结构更接近于 Field 表示,因此可以起到双重作用。一个完整的模式可以表示为 Struct 类型,其子成员为模式的各个字段。这种重用保持了 ABI 的简洁,仅需 ArrowSchema 和 ArrowArray 结构即可,而无需另外引入表示字段的结构。
以下是 ArrowSchema 结构的定义:
#define ARROW_FLAG_DICTIONARY_ORDERED 1
#define ARROW_FLAG_NULLABLE 2
#define ARROW_FLAG_MAP_KEYS_SORTED 4
struct ArrowSchema {
// 数组类型描述
const char* format;
const char* name;
const char* metadata;
int64_t flags;
int64_t n_children;
struct ArrowSchema** children;
struct ArrowSchema* dictionary;
// 释放回调
void (*release)(struct ArrowSchema*);
// 生产者特定的私有数据
void* private_data;
};
简单、直接且易于使用,对吧?让我们看看不同的字段:
-
const char* format(必需) :这是一个以 null 结尾的 UTF8 编码字符串,描述数据类型。嵌套数组的子数据类型编码在子结构中。 -
const char* name(可选) :这是一个以 null 结尾的 UTF8 编码字符串,包含字段或数组的名称。它主要用于重建嵌套类型的子字段。如果省略,可以为 NULL 或空字符串。 -
const char* metadata(可选) :这是一个包含类型元数据的二进制字符串,不以 null 结尾。其格式在图 4.6 中描述。 -
int64_t flags(可选) :这是通过对标志值进行按位 OR 操作计算出的标志位。如果省略,应该为 0。可用的标志是结构上方定义的宏:ARROW_FLAG_NULLABLE:如果字段允许为空,则设置此标志(无论数组中是否实际存在空值)。ARROW_FLAG_DICTIONARY_ORDERED:如果这是字典编码类型,且索引的顺序在语义上有意义,则设置此标志。ARROW_FLAG_MAP_KEYS_SORTED:对于 map 类型,如果每个值中的键是排序的,则设置此标志。
-
int64_t n_children(必需) :这是嵌套数据类型的子项数量,或者为 0。 -
struct ArrowSchema** children(可选) :在这个 C 数组中,必须有与n_children相等的指针数。如果n_children为 0,则此字段可以为空。 -
struct ArrowSchema* dictionary(可选) :如果此模式表示字典编码类型,则此字段必须为非空,并指向字典值类型的模式。否则,此字段必须为空。 -
void (*release)(struct ArrowSchema*)(必需) :这是一个指向回调函数的指针,用于释放此对象的相关内存。 -
void *private_data(可选) :如果需要,这是一个由生产 API 提供的指向某个不透明数据结构的指针。消费者不得处理此成员;其指向的内存的生命周期由生产 API 管理。
注意,format 字段使用字符串描述数据类型。让我们看看如何从这个字符串中确定数据类型。
数据类型格式
数据类型本身由一个格式字符串描述,该字符串仅描述顶层类型的格式。嵌套数据类型的子类型将出现在子模式对象中,而元数据则编码在一个单独的成员字段中。所有格式字符串的设计都非常易于解析,最常见的基本类型格式每个都有一个单字符的格式字符串。基本类型的格式如图 4.1 所示:
一些带有参数的数据类型,例如十进制类型和定宽二进制类型,其格式字符串由单个字符和一个冒号组成,后跟参数,如图 4.2 所示:
接下来的一组类型是时间类型:日期和时间。这些格式字符串都是以 t 开头的多字符字符串,如图 4.3 所示。对于可能包含指定时区的时间戳类型,时区直接跟在冒号后面,无需加引号。如果时区为空,冒号字符仍不可省略,必须包括在内:
请注意,字典编码类型没有特定的格式字符串来表明它们是以这种方式编码的。对于字典编码的数组,ArrowSchema 的格式字符串指示索引类型的格式,而字典值类型由 dictionary 成员字段描述。例如,使用毫秒作为单位且没有时区、以 int16 作为索引的字典编码时间戳数组,其格式字符串为 s,而 dictionary 成员字段的格式字符串为 tsm:。
最后是嵌套类型。所有嵌套类型,如图 4.4 所示,都是以 + 开头的多字符格式字符串:
与字典编码数组类似,嵌套类型的子字段的数据类型和名称将位于子模式的数组中。考虑 Map 数据类型时要记住,根据 Arrow 格式规范,它应该始终有一个名为 entries 的子类型,该子类型本身是一个结构体,包含两个名为 key 和 value 的子类型。
关于模式描述的最后一个重要部分是为字段或记录批次编码元数据。
元数据的编码方案
在模式对象中的 metadata 字段需要将一系列键-值对表示为一个二进制字符串。为此,元数据键值对被编码为一种定义的格式,既易于解析,又紧凑:
图 4.5 展示了编码元数据的格式,并给出了一个使用键值对([('Gummi', 'Bear'), ('Penny', 'Logan')])的示例。元数据中的 32 位整数将根据平台的本地字节序编码,因此相同的元数据在小端机器上会是这样的:
\x02\x00\x00\x00\x05\x00\x00\x00Gummi\x04\x00\x00\x00Bear\x05\x00\x00\x00Penny\x05\x00\x00\x00Logan
注意,键和值的字节不是以 null 结尾的。在大端机器上,相同的元数据将表示为:
\x00\x00\x00\x02\x00\x00\x00\x05Gummi\x00\x00\x00\x04Bear\x00\x00\x00\x05Penny\x00\x00\x00\x05Logan
仔细看看定义键数量和字符串长度的四字节组。在字节序的差异上,数字的字节顺序不同,大端机器将最高有效字节放在最前,而小端机器则将其放在最后。
扩展类型呢?
细心的读者可能会注意到,在这些数据类型格式字符串中缺少了一种类型——规范或用户定义的扩展类型。回顾一下,扩展类型为 Arrow 用户提供了一种定义自己数据类型的方法,基于现有类型并通过定义元数据键。ArrowSchema 结构的 format 字段指示数组的存储类型,因此不会直接指示扩展类型。相反,这类信息会通过元数据中的 ARROW:extension:name 和 ARROW:extension:metadata 键进行编码。导出扩展类型的数组实际上只是导出存储类型数组,同时添加这些元数据键值对。
表/记录批次的表示
表或记录批次由多列组成。然而,你可能注意到,ArrowSchema 实例只能表示一列及其子列。你能猜出如何使用 C 接口表示并传递表或记录批次的模式吗?
稍作思考……
你有答案了吗?让我们看看你是否正确!看看图 4.6。
要使用 ArrowSchema 表示表格,只需将其视为一个结构体数组,其子元素为表格/记录批次的列。这种方法使我们能够轻松维护单个 C 结构表示,并且 API 可以同时处理单个数组以及完整的记录批次或表格。在表示记录批次的数据时,结构与 ArrowArray 结构相同。
练习
在我们继续描述数组数据之前,尝试做一些练习。如果遇到困难,可以查看文档:arrow.apache.org/docs/format…
- 如何使用格式字符串和该结构表示一个使用
uint32作为索引的字典编码的decimal128(precision=12, scale=5)数组的模式? - 那么
list<uint64>数组的模式如何表示呢? - 现在换一下,尝试表示
struct<ints: int32, floats: float32>数据类型。 - 再试试表示一个
map<string, float64>类型的数组。
好了,现在我们继续讨论数据本身。
ArrowArray 结构
要解释一个 ArrowArray 实例并使用所描述的数据,首先需要知道其模式或数组类型。这可以通过约定来确定,例如 API 始终生成的某种类型,或者通过将对应的 ArrowSchema 对象与 ArrowArray 对象一起传递来确定。以下是该结构的定义:
struct ArrowArray {
int64_t length;
int64_t null_count;
int64_t offset;
int64_t n_buffers;
int64_t n_children;
const void** buffers;
struct ArrowArray** children;
struct ArrowArray* dictionary;
// 释放回调
void (*release)(struct ArrowArray*);
// 生产者相关的私有数据
void* private_data;
};
如果仔细看,它本质上遵循了 Arrow 格式规范对数组的描述。以下是每个字段的用途,以及它是必需的还是可选的:
int64_t length(必需) :这是数组的逻辑长度,即项目的数量。int64_t null_count(必需) :这是数组中空值的数量。如果尚未计算,可以为 -1。int64_t offset(必需) :这是数组在缓冲区开始之前的物理元素数量。通过设置缓冲区的长度和偏移量,允许重用缓冲区并对其进行切片。必须为 0 或正整数。int64_t n_buffers(必需) :这是与该数组相关的数据所分配的缓冲区数量,取决于数据类型,如第 1 章中所述。子数组的缓冲区不包括在内;该值必须与 C 缓冲区数组的长度匹配。const void** buffers(必需) :这是一个 C 数组,指向与该数组相关的每个数据缓冲区的起始位置。每个void*指针必须指向内存的连续块的起始位置,或者为 null。此数组中必须有与n_buffers一致的指针。如果null_count为 0,可以通过将其指针设置为 null 来省略 null 位图缓冲区。int64_t n_children(必需) :这是该数组的子数组数量,取决于数据类型。例如,List 数组应该有 1 个子数组,而 Struct 数组的每个字段都有 1 个子数组。ArrowArray** children(可选) :这是一个 C 数组,指向该数组的每个子数组的ArrowArray实例。此数组中必须有与n_children一致的指针。如果n_children为 0,此字段可以为 null。ArrowArray* dictionary(可选) :如果数据是字典编码的,则这是指向字典值数组的指针。如果数组是字典编码数组,此指针必须有效;如果不是字典编码数组,此字段必须为 null。void (*release)(struct ArrowArray*)(必需) :这是一个指向回调函数的指针,用于释放此对象的相关内存。这个回调的目的是由消费者来管理数据的生命周期,确保数据的内存在此函数调用之前始终有效。此外,对于嵌套数组,回调应仅在顶级数组上调用,因为release回调应该递归释放每个子数组。void *private_data(可选) :如果需要,这是一个由生产 API 提供的指向某个不透明数据结构的指针。消费者不得处理此成员;其生命周期由生产 API 处理。
那么,为什么要使用这个结构?在什么情况下与您相关?让我们继续探讨。
示例用例
拥有 C 数据 API 的一个重要好处是,它允许应用程序实现 API,而无需依赖 Arrow 库。假设有一个现有的用 C++ 编写的计算引擎,希望在不添加新依赖项或链接 Arrow 库的情况下,增加以 Arrow 格式返回数据的能力。避免在项目中添加新依赖项的原因有很多,这可能与开发环境、部署机制的复杂性等有关,但我们不会专注于这一点。
使用 C 数据 API 导出 Arrow 格式数据
你是否已经为 C++ 设置好了开发环境?如果没有,请先完成设置再回来。你知道的,我会等你的。
我们将从一个小函数开始,生成一个随机的 32 位整数向量,作为我们的示例数据。你知道怎么做吗?很好。在查看我的基本随机数据生成器代码片段之前,自己先试试看,如下所示:
#include <algorithm>
#include <limits>
#include <random>
#include <vector>
#include <memory>
std::vector<int32_t> generate_data(size_t size) {
static std::uniform_int_distribution<int32_t> dist(
std::numeric_limits<int32_t>::min(),
std::numeric_limits<int32_t>::max());
static std::random_device rnd_device;
std::default_random_engine generator(rnd_device());
std::vector<int32_t> data(size);
std::generate(data.begin(), data.end(), [&]() {
return dist(generator); });
return data;
}
请注意,我们在函数中使用了 static 关键字,因此随机设备只会实例化一次。然而,这会导致该函数在多线程环境中不是线程安全的。
现在我们有了随机数据生成器函数,可以利用 C 数据 API 导出数据了:
首先,从 Arrow 仓库下载 C 数据 API 的副本,链接为 raw.githubusercontent.com/apache/arro…
确保在本地有该头文件;它包含我之前提到的 ArrowSchema 和 ArrowArray 结构的定义。
接下来,我们需要创建一个导出数据的函数。为了模拟通过这个 C API 导出数据的引擎,它将是一个仅接受指向 ArrowArray 结构的指针的函数。我们知道要导出 32 位整数数据,因此这个示例不会传递 ArrowSchema。以下函数将用我们随机生成的数据填充传入的结构:
函数签名将接受一个指向 ArrowArray 对象的指针:
void export_int32_data(struct ArrowArray* array) {
首先,我们将创建一个 std::unique_pointer 对象,指向使用我们的数据生成器生成的向量:
std::unique_ptr<std::vector<int32_t>> data = std::make_unique<std::vector<int32_t>>(generate_data(1000));
现在,我们可以构建我们的对象。注意高亮的行,指示我们在缓冲区分配数组和释放回调函数的地方,同时维护指向数据向量的指针。我们知道长度将是向量的大小,并且将有两个缓冲区,null 位图和原始数据。通过将 release 回调设置为 null,我们可以标记一个 ArrowArray 对象为已释放,这在释放 Lambda 函数的最后一步进行:
*array = ArrowArray{
1000, // length
0, // null_count
0, // offset
2, // n_buffers (validity and data)
0, // n_children
new const void*[2]{nullptr, reinterpret_cast<void*>(data->data())}, // buffers
nullptr, // children
nullptr, // dictionary
[](struct ArrowArray* arr) { // release
delete[] arr->buffers;
delete reinterpret_cast<std::vector<int32_t>*>(arr->private_data);
arr->release = nullptr;
},
reinterpret_cast<void*>(data.release()), // private_data
};
到目前为止,我们有一个创建函数的 C++ 文件,但我们希望将其导出为 C 兼容的 API。我们需要做的就是在我们刚创建的函数定义之前添加这个代码片段:
extern "C" {
void export_int32_data(struct ArrowArray*);
}
一些解释
如果你熟悉 C++ 和 C 的链接差异,你就会明白这样做的好处。对于其他人来说,简而言之,C++ 会以特定方式对你导出的函数名进行名称重整。通过将函数声明为 extern "C",你告知编译器你的意图,保持此函数名称未被重整,因为你希望它能够被 C 外部链接。
现在,我们编译这个文件并创建一个可供调用此函数的小共享库。假设你使用的是 g++ 编译器,使用以下命令:
$ g++ -fPIC -shared -o libsample.so example_cdata.cc
这将创建一个共享对象文件,扩展名为 .so,任何能够调用 C API 的程序都可以使用它来调用我们刚刚创建的导出函数。我们没有包含任何 Arrow C++ 头文件,仅包含 C 结构的副本,也没有链接任何 Arrow 库。这使得任何调用者,无论使用何种语言、运行时或库,都可以通过指向 ArrowArray 结构的指针调用该函数,并用一块数据填充它。
我们可以用这个库做什么?
那么,我们可以用这个库做什么呢?让我们写点东西,从非 C++ 运行时调用它。是的,我知道——这是一个简单的示例。然而,你是一个有创造力的人,对吧?我相信你可以将我在这里提出的概念和一般示例改编用于许多创造性的目的。让我们写一个小脚本,从 Python 调用这个 API。
致敬 nanoarrow
我曾经简要提到过一次,但现在也是提到 nanoarrow 项目的好时机。虽然 ArrowArray 和 ArrowSchema 对象相对简单,但正确构建它们可能是一个艰巨的任务,存在很多小问题。为此,Arrow 社区创建了一个非常小的 C 库,名为 nanoarrow。该库的目的是被集成到项目中,或以静态链接的方式使用,以便轻松与 Arrow C API 结构进行交互和验证。书中 GitHub 仓库中的示例代码包含一个额外的函数 export_int32_data_nanoarrow 来演示这一点。示例代码中的 CMakeLists.txt 文件会为你下载并编译 nanoarrow,并链接以生成共享对象。
使用 Python 导入 Arrow 数据
提供接口以调用其他运行时或语言的 API 的常用术语是 FFI。在 Python 中,我们将使用一个名为 cffi 的库,它被 pyarrow 模块用于实现 C 数据 API。确保你在与我们在上一个练习中创建的 libsample.so 库文件相同的目录中运行我们即将编写的脚本:
首先,导入部分——以下高亮行表示我们正在导入作为 pyarrow 模块一部分的已编译 ffi 库:
import pyarrow as pa
from pyarrow.cffi import ffi
集成 FFI 模块有几种不同的方法,但在本练习中,我们将使用通过 cdef 函数定义的接口动态加载库。然后,我们将使用 dlopen 加载共享对象库:
ffi.cdef("void export_int32_data(struct ArrowArray*);")
lib = ffi.dlopen("./libsample.so")
请注意,这与我们之前的 extern "C" 声明相匹配。
现在,我们可以创建一个 ArrowArray 结构,并调用我们导出的函数以填充它:
c_arr = ffi.new("struct ArrowArray*")
c_ptr = int(ffi.cast("uintptr_t", c_arr))
lib.export_int32_data(c_arr)
接下来,我们使用 pyarrow 模块导入数据。请记住,由于我们传递的是指针,因此数据缓冲区不会被复制。因此,无论该数组有 1,000 个元素还是 1,000,000 个元素,我们在这里都不会复制数据。导入数据只是将所有内容连接到内存中的正确区域:
arrnew = pa.Array._import_from_c(c_ptr, pa.int32())
# 对数组执行操作
del arrnew # 将在垃圾收集时调用释放回调
如果愿意,你可以在这里添加打印语句,以确认它确实按预期工作。这就是全部。我们创建的库可以以我们想要的任何方式创建数据数组,但只要它正确填充 C 结构,我们就能够在 Arrow 格式中传递数据,而无需复制它。
如果我们想朝另一个方向工作呢?
我们可以使用 Python 读取数据,然后将其交给更快速的处理,就像 Spark 使用 JPype 将数据从 Python 传递到 Java 而不进行复制一样。我们该如何实现呢?
使用 C 数据 API 从 Python 导出 Arrow 数据到 Go
在这个例子中,我们将添加一个层级,通过 C 数据 API 从 Python 转到 Go。如前所述,Python 和 Go 的 Arrow 库直接实现了 C 数据 API,因此你可以轻松地将数据导出到库中并从中导入数据。让我们开始吧!
我们首先要做的是在 Go 中创建一个共享库,以导出一个可以调用的函数。我们将简单地导入一个传入的 Arrow 记录批次,并将模式、列数和行数打印到终端。你可以将打印到终端的操作替换为其他操作,例如调用现有的 Go 库、执行计算或使用你可能更喜欢的数据进行任何其他操作。我只是展示如何连接,以高效地传递数据,而无需执行任何复制。
构建 Go 共享库
由于我们使用了 Cgo,你需要确保在 Go 开发环境中可以找到 C 和 C++ 编译器。如果你使用的是 Linux 或 macOS 机器,通常可以轻松访问 gcc 和 g++。在 Windows 上,你必须使用 MSYS2 或 MinGW 开发环境,并安装 gcc 和 g++ 来构建它。
重要说明
使用 Cgo 可能会涉及许多细微差别和注意事项,我不会详细讨论,但我强烈建议你阅读文档,地址是 pkg.go.dev/cmd/cgo。
接下来的步骤将引导你使用 Go 构建一个动态库,该库导出一个可以作为 C 兼容 API 调用的函数:
首先,创建我们将要导出的函数。我们先初始化一个 Go 模块:
$ go mod init sample
在创建动态库时,我们使用 main 包,类似于创建可执行二进制文件。因此,创建我们的 sample.go 文件:
package main
import (
"fmt"
"github.com/apache/arrow/go/v17/arrow/cdata"
)
func processBatch(scptr, rbptr uintptr) {
schema := cdata.SchemaFromPtr(scptr)
arr := cdata.ArrayFromPtr(rbptr)
rec, err := cdata.ImportCRecordBatch(arr, schema)
if err != nil {
panic(err) // 处理错误!
}
// 确保在完成时调用释放回调
defer rec.Release()
fmt.Println(rec.Schema())
fmt.Println(rec.NumCols(), rec.NumRows())
}
注意高亮的行。函数签名接受两个 uintptr 值,即我们结构体的指针。我们使用 Arrow 模块中的 cdata 包来导入我们的记录批次。
记住
“但等等,”你会说。“C 数据 API 中没有记录批次的格式字符串。”你是对的!回顾图 4.6,我描述了记录批次的表示方式。导入记录批次只是将结构体数组展平为记录批次。
现在我们有了要导出的函数,我们只需……好吧,导出它:
package main
import (
"fmt"
"github.com/apache/arrow/go/v17/arrow/cdata"
)
import "C"
//export processBatch
func processBatch(scptr, rbptr uintptr) {
...
}
func main() {}
高亮的行是导出函数所需的附加内容。import "C" 行告诉 Go 编译器使用 Cgo 命令,这将识别导出注释。双斜杠与单词 export 之间不能有空格,且后面必须紧跟函数名称。如果不匹配,编译器会报错。最后,添加主函数是 Go 的运行时与 C 运行时连接所必需的。
最后,我们可以构建我们的库:
bash
复制代码
$ go build -buildmode=c-shared -o libsample.so .
将 buildmode 参数设置为 c-shared 告诉编译器我们正在创建一个动态库。在 Windows 上,你需要将 libsample.so 改为 libsample.dll,但其他一切相同。构建库还会创建一个名为 libsample.h 的新文件——供 C 或 C++ 程序包含以调用导出函数的头文件。
我们可以像之前调用 C++ 动态库时那样使用 cffi Python 模块来调用这个函数,但在这个示例中,我们将采取不同的方法,以展示一些不同的选择。
从 Go 库创建 Python 扩展
我们将使用 cffi 模块创建所谓的 Python C 扩展。在开始之前,请确保你在与创建共享库时的 libsample.h 和 libsample.so 文件相同的目录中有之前从 Arrow GitHub 仓库复制的 abi.h 文件:
我们首先创建一个构建脚本,使用 cffi 模块编译我们的扩展。让我们将其命名为 example_go_cffi.py:
import os
from pyarrow.cffi import ffi
ffi.set_source("_sample",
r"""
#include "abi.h"
#include "libsample.h"
""",
library_dirs=[os.getcwd()],
libraries=["sample"],
extra_link_args=[f"-Wl,-rpath={os.getcwd()}"])
ffi.cdef("""
void processBatch(uintptr_t, uintptr_t);
""")
if __name__ == "__main__":
ffi.compile(verbose=True)
调用 cdef 函数的方式对你来说应该很熟悉,这正是我们之前加载 C++ 共享库时所做的。唯一的例外是现在它匹配我们导出的 processBatch 函数的签名。
看高亮的行,我们可以看到头文件同时包含 C API 的 abi.h 头文件和我们构建生成的头文件。然后,我们指定库所在的目录及其名称;注意缺少 lib 前缀,这将自动处理。我们传递的额外链接参数包含 rpath 规范,以便在运行时更方便。该链接器的参数告知系统在运行时查找依赖库的目录——在我们的例子中,它告诉系统 libsample.so 的位置,以便在调用扩展时加载。
现在,通过调用我们的构建脚本来构建扩展:
$ python3 example_go_cffi.py
这将生成一个名为 _sample.cpython-39-x86_64-linux-gnu.so 的文件(如果你在 Linux 机器上使用 Python 3.9)。文件名将根据你使用的 Python 版本和你构建的操作系统进行调整。现在可以由 Python 导入并使用这个扩展,让我们试试看!
在你构建扩展的同一目录中启动 Python 解释器,以便我们可以导入它:
>>> from _sample import ffi, lib
请注意,我们从扩展中导入了两个内容:ffi 模块和库本身。
我们将使用之前的示例中的同一个 Parquet 文件 yellow_tripdata_2015-01.parquet 来测试扩展。使用 to_batches 函数从表中创建记录批次:
>>> import pyarrow.parquet as pq
>>> tbl = pq.read_table('<path to file>/yellow_tripdata_2015-01.parquet')
>>> batches = tbl.to_batches(None)
由于默认情况下读取 Parquet 文件是多线程的,因此表会分多个块读取。这会导致在调用 to_batches 时生成多个记录批次。在我的机器上,我得到了一个包含 13 个记录批次的列表;在这个示例中,我们只使用其中一个批次。
和以前一样,我们使用 ffi 创建我们的 ArrowSchema 和 ArrowArray 对象及其指针,然后将它们转换为 uintptr_t:
>>> c_schema = ffi.new('struct ArrowSchema*')
>>> c_array = ffi.new('struct ArrowArray*')
>>> ptr_schema = int(ffi.cast('uintptr_t', c_schema))
>>> ptr_array = int(ffi.cast('uintptr_t', c_array))
接下来,我们导出记录批次并调用我们共享的函数:
>>> batches[0].schema._export_to_c(ptr_schema)
>>> batches[0]._export_to_c(ptr_array)
>>> lib.processBatch(ptr_schema, ptr_array)
在我们编写 Go 共享库时,函数最后会打印出记录批次的模式、列数和行数。在执行高亮行后,你应该看到这些信息都打印在终端上。列和行的数量应跟随模式:
19 1048576
如果你有很多批次要传递,反复调用可能显得有些繁琐。多次传递模式可能也效率不高。由于你经常会处理数据流(例如使用 Spark 时),需要能够通过这个 API 流式传输记录批次。这就是为什么 C 数据 API 的头文件中还有一个结构体 ArrowArrayStream 的原因。
在 Python 和 Go 之间流式传输 Arrow 数据
C 流式 API 是建立在初始的 ArrowSchema 和 ArrowArray 结构之上的高级抽象,旨在简化在同一进程中跨 API 边界流式传输数据。该流的设计目的是提供一个块拉取 API,该 API 一次从源中拉取一块数据,所有块都具有相同的模式。其结构定义如下:
struct ArrowArrayStream {
// 流功能的回调
int (*get_schema)(struct ArrowArrayStream*, struct ArrowSchema*);
int (*get_next)(struct ArrowArrayStream*, struct ArrowArray*);
const char* (*get_last_error)(struct ArrowArrayStream*);
// 释放回调和私有数据
void (*release)(struct ArrowArrayStream*);
void* private_data;
};
释放回调和 private_data 成员现在应该对你很熟悉,其他成员的说明如下:
int (*get_schema)(struct ArrowArrayStream*, struct ArrowSchema*)(必需) :这是用于检索数据块模式的回调函数。所有块必须具有相同的模式。在对象被释放后不能调用此函数。成功时必须返回 0,失败时返回非零错误代码。int (*get_next)(struct ArrowArrayStream*, struct ArrowArray*)(必需) :这是用于从流中返回下一个数据块的回调函数。在对象被释放后不能调用此函数。成功时必须返回 0,失败时返回非零错误代码。在成功时,消费者应检查流是否已被释放。如果是,则流已结束;否则,ArrowArray数据应有效。const char* (*get_last_error)(struct ArrowArrayStream*)(必需) :此回调函数仅在上一个调用的函数返回错误时可以调用,且不能在释放的流上调用。它返回一个以 null 结尾的 UTF-8 字符串,该字符串在下一次调用流的回调之前有效。
从回调函数填充的模式和数据块的生命周期与 ArrowArrayStream 对象的生命周期完全无关,必须独立清理和释放,以避免内存泄漏。失败时的非零错误代码应与平台的 errno 值进行相同的解释。
现在,我们可以更新之前的示例,从 Parquet 文件流式传输记录批次到共享库。再次强调,这是一个简单的例子,因为我们可以同样轻松地在 Go 中原生读取 Parquet 文件,但概念是关键。在 Python 中读取 Parquet 文件也可以很容易地接收 pandas DataFrame,然后将其转换为 Arrow 记录批次并发送:
首先,我们修改我们的 Go 文件,添加一个接受指向 ArrowArrayStream 对象的指针的新函数,并循环遍历我们的批次流。导入 Stream 对象时,Arrow 库已经处理了困难的部分。
如前所述,我们需要在注释行中添加高亮的内容,以便 Go 导出具有 C 兼容接口的函数:
//export processStream
func processStream(ptr uintptr) {
然后,我们可以在 var 块中初始化我们将要使用的变量:
var (
arrstream = (*cdata.CArrowArrayStream)(unsafe.Pointer(ptr))
rec arrow.Record
err error
x int
)
现在,我们可以创建一个流读取器对象,传递 nil 作为模式参数,以便它知道从流对象本身提取模式,而不是我们提供:
rdr := cdata.ImportCArrayStream(arrstream, nil)
最后,我们使用循环,调用读取器的 Read 方法,直到遇到非 nil 错误。当我们到达流的末尾时,它应该返回 io.EOF;任何其他错误则表示我们遇到了问题:
for {
rec, err = rdr.Read()
if err != nil {
break
}
fmt.Println("Batch: ", x, rec.NumCols(), rec.NumRows())
rec.Release()
x++
}
if err != io.EOF {
panic(err) // 处理错误!
}
} // 函数结束
像以前一样,使用 Cgo 命令和 buildmode 选项构建库。别忘了 import "C";否则,它将不会生成头文件:
$ go build -buildmode=c-shared -o libsample_stream.so .
修改 Python 扩展构建脚本以使用新库和函数;如果你没有删除 processBatch 函数,甚至可以在同一个库中拥有两个函数。如果不更新构建脚本,新函数 processStream 将无法从 Python 调用:
import os
from pyarrow.cffi import ffi
ffi.set_source("_sample_stream",
r"""
#include "abi.h"
#include "libsample_stream.h"
""",
library_dirs=[os.getcwd()],
libraries=["sample_stream"],
extra_link_args=[f"-Wl,-rpath={os.getcwd()}"])
ffi.cdef("""
void processBatch(uintptr_t, uintptr_t);
void processStream(uintptr_t);
""")
if __name__ == "__main__":
ffi.compile(verbose=True)
然后,像之前一样运行此命令来构建新的 _sample_stream 扩展。
最后,让我们打开 Parquet 文件,然后调用我们的函数以流式传输数据:
from _sample_stream import ffi, lib
import pyarrow as pa
import pyarrow.parquet as pq
f = pq.ParquetFile('yellow_tripdata_2015-01.parquet')
batches = f.iter_batches(1048756)
rdr = pa.ipc.RecordBatchReader.from_batches(f.schema_arrow, batches)
c_stream = ffi.new('struct ArrowArrayStream*')
ptr_stream = int(ffi.cast('uintptr_t', c_stream))
rdr._export_to_c(ptr_stream)
del rdr, batches
lib.processStream(ptr_stream)
如果你运行此脚本,你应该会在终端看到以下输出,调用我们用 Go 构建的共享库:
Batch: 0 19 1048576
Batch: 1 19 1048576
Batch: 2 19 1048576
Batch: 3 19 1048576
Batch: 4 19 1048576
Batch: 5 19 1048576
Batch: 6 19 1048576
Batch: 7 19 1048576
Batch: 8 19 1048576
Batch: 9 19 1048576
Batch: 10 19 1048576
Batch: 11 19 1048576
Batch: 12 19 163914
看看吧——你不仅创建了一些动态扩展来传递 Arrow 数据,还通过不同的编程语言传递了数据!是不是很酷?下次你尝试在不同语言中连接不同技术时,想想这个示例,看看你还能在哪里应用这些技术。
实验性但有用
虽然 PyArrow 中的 _import_from_c 和 _export_to_c 函数在此用例中非常有用,但它们也有一些缺点。特别是,它们确实需要安装 PyArrow,并由此需要安装整个 C++ libarrow 库。为了为 Python 使用创建更好的标准,避免内存泄漏,并允许在不需要 Arrow 库本身的情况下与使用 Arrow 数据的库进行接口,我们定义了一个利用名为 PyCapsules 的东西的接口(docs.python.org/3/c-api/cap…)。你可以在 Arrow 文档中找到有关此实验性协议的文档,地址为 arrow.apache.org/docs/format…。
非 CPU 设备数据怎么样?
在上一章的末尾,即第 3 章《格式和内存处理》中,我提到了利用 Arrow 与 GPU 和其他非 CPU 设备的主题。随着数据预处理分析工作流需要满足机器学习模型的数据需求,这个话题变得越来越重要。数据科学家常用几种不同的库进行基于 GPU 的分析。以下是一些例子:
- Numba:一个开源的即时编译器(JIT),可以将 Python 和 NumPy 的子集翻译成低级机器代码,并提供在 CPU 和 GPU 上并行化 Python 代码的选项。
- XGBoost:一个开源库,提供优化的分布式梯度提升算法,同时支持在 GPU 上运行。
- PyTorch:一个开源的机器学习库,通常用于计算机视觉和自然语言处理,也支持在 NVIDIA GPU 上运行以提升性能。
- NVIDIA RAPIDS 和 cuDF:一套开源库,包括数据框表示,以加速 NVIDIA GPU 上的数据科学。
虽然使用 GPU 进行计算可以带来巨大的速度提升,但这种编程范式需要适应。性能的一个最大瓶颈是必须在主机内存和设备内存之间来回复制数据。因此,复制的次数越少,处理的性能就越好!
ArrowDeviceArray 结构体
这与我们正在讨论的 C 数据 API 有什么关系呢?你可能会注意到,在本章开始时提到的两个结构体,即 ArrowDeviceArray 和 ArrowDeviceArrayStream,我们还没有涵盖。这些结构体可以用于在不同的库、语言和运行时之间传递数据,而无需进行主机到设备、设备到主机的数据复制。
不再重复
我不会在这里详细说明 ArrowDeviceArrayStream 结构体,因为它与我们已经讨论过的 ArrowArrayStream 结构体几乎相同,只是回调函数中使用的是 ArrowDeviceArray,而不是 ArrowArray。唯一的附加项是 ArrowDeviceType 成员,因为预计来自流的所有数组都应位于同一设备上(尽管它们的设备 ID 可能不同)。
为了让这个内容更易于理解,我为你准备了一些图表。在图 4.7 中,我们可以看到一个潜在工作流的序列图,展示了如何将数据从 Python 中的 Numba 库传递到 C++ 中的 libcudf。
从上到下,图中显示的步骤如下:
- 数据首先从主内存复制到 GPU。
- Numba 协调对数据的 GPU 操作。
- 结果数据通过 C++ 函数调用从 GPU 复制回主内存,以交给 libcudf。
- 然后,libcudf 将 Numba 结果复制回 GPU 进行操作。
- 最后,最终结果被复制回主内存。
另外,图 4.8 显示了相同的序列图,只不过这次使用了 ArrowDeviceArray 结构体在库之间传递设备指针,而无需复制数据。
从上到下,新步骤如下:
- 数据首先从主内存复制到 GPU。
- Numba 协调对数据的 GPU 操作。
- 用指向中间数据的指针填充一个 ArrowDeviceArray 结构体,并将其传递给 libcudf(稍后会详细介绍事件同步部分!)。
- libcudf 在已经在 GPU 内存中的数据上进行操作。
- 最终结果被复制回主内存。
我们稍后会用一些代码回到这些图表,但首先,让我们来看看 ArrowDeviceArray 结构体:
typedef int32_t ArrowDeviceType;
struct ArrowDeviceArray {
struct ArrowArray array;
int64_t device_id;
ArrowDeviceType device_type;
void* sync_event;
// 为未来扩展保留的字节
int64_t reserved[3];
};
这个定义相当简单,对吧?我们在现有的 ArrowArray 结构体上添加了一些额外的信息:
struct ArrowArray array(必需) :这是数据本身,如前所述。缓冲区指针应指向所指示设备上的内存。结构体的其余部分应对 CPU 可访问。释放设备内存所需的任何内容应作为结构体的private_data和release回调成员的一部分。int64_t device_id(必需) :如果存在多个所指示类型的设备,这表示数据所在的设备实例。ArrowDeviceType device_type(必需) :这是一个 32 位整数,标识数据位于内存中的设备类型。已知的设备类型在 Arrow 规范中定义了宏,例如ARROW_DEVICE_CPU和ARROW_DEVICE_CUDA。这些可以在文档中找到,地址为 Arrow Device Data Interface。void* sync_event(可选) :这是一个指向事件对象的指针,用于必要时进行同步。大多数设备(如 GPU)相对于 CPU 是异步操作的。这意味着在尝试访问设备指针时,可能会出现竞争条件。我们允许使用设备事件,以便消费者可以选择何时进行同步,而不是强迫数据生产者在传递之前进行同步。如果不需要同步,则此值应为 null。如果此值不为 null,则必须在尝试访问缓冲区中的内存之前用于调用适当的同步方法(例如cudaStreamWaitEvent或hipStreamWaitEvent)。int64_t reserved[3]:为了允许在不破坏 ABI 的情况下扩展此结构,保留了对象末尾的 24 字节。这些字节在初始化后必须置为零,以确保在未来进行安全扩展,因为非 CPU 开发不断增加。
现在我们已经涵盖了这个结构体,接下来让我们深入一些代码!
使用 ArrowDeviceArray
这个代码示例将展示在图 4.8 中描述的工作流程,利用 Python 中的 Numba 和 C++ 中的 libcudf。虽然这是一个相当牵强的示例,但它展示了如何利用 ArrowDeviceArray 结构体在不同库之间连接数据,同时避免不必要的复制。此外,你需要访问带有 Nvidia 显卡的设备,因为代码示例需要使用 CUDA 库。
重要提示
为简洁起见,这里不会展示完整的代码。请确保查看本书的 GitHub 仓库,其中包含在本节其余部分引用的完整代码。
我们的示例的第一步是安装一些依赖项:
- 有关安装 CUDA 工具包的说明,请参见文档(CUDA Downloads)。
- libcudf 可以按照 Nvidia RAPIDS 网站上的文档说明进行安装(RAPIDS Installation)。
- Numba 可以通过
pip install numba安装,也可以使用 conda 安装。
在我们解决依赖关系后,首先,我们构建一个小型 C++ 共享库,以传递我们的 ArrowDeviceArray 结构体。让我们创建一个简单的函数,该函数接受一个输入数组并使用求和聚合将其减少为单个值。我们在名为 get_sum.cc 的文件中编写该函数:
void get_sum(ArrowSchema* in_schema,
ArrowDeviceArray* input,
ArrowSchema* out_schema,
ArrowDeviceArray* output) {
对于输入和输出,我们使用一对 ArrowSchema 和 ArrowDeviceArray 结构体。现在,让我们深入该函数的核心:
// cudf 将内部处理事件的同步
auto col = cudf::from_arrow_device(in_schema, input);
auto sumagg = cudf::make_sum_aggregation();
auto scalar = cudf::reduce(col,
*dynamic_cast<cudf::reduce_aggregation*>(
sumagg.get()), col->type());
auto result = cudf::make_column_from_scalar(*scalar, 1);
ArrowSchemaMove(udf::to_arrow_schema(
cudf::table_view({*result}),
std::vector<cudf::column_metadata>{{"result"})
.get(), out_schema);
// to_arrow_device 将填充同步事件
ArrowDeviceArrayMove(cudf::to_arrow_device(
cudf::table_view({*result})).get(), output);
ArrowArrayRelease(&input->array);
ArrowSchemaRelease(in_schema);
}
快速说明
我在前面的代码中遗漏的一件事是一些验证。严格来说,在导入数组后,我们应该使用 cudf::is_numeric 验证我们得到了一个数值数组,并在没有得到数值数组时报告错误。否则,调用 cudf::reduce 进行聚合时将抛出异常。
这太可惜了,对吧?幸运的是,libcudf 已经直接支持 ArrowSchema 和 ArrowDeviceArray 之间的转换。现在我们只需要构建共享对象。我们将使用 cmake 来构建这个示例,因为它更容易管理依赖关系。我们从以下命令开始:
$ cmake -B build -S . -DWITH_CUDA=ON
这将创建一个名为 build 的目录,其中包含各种构建脚本、Makefiles 等。通过添加 WITH_CUDA 选项,cmake 还将检查 libcudf 以链接。假设一切顺利,你可以通过执行以下命令进行构建:
$ cmake --build build
构建完成后,构建目录将包含前一个示例(libexample-cdata.so)和我们的 get_sum 函数库:libget-sum.so。如果你在 Windows 上,扩展名将为 .dll,而在 macOS 上,将为 .dylib。
好了,现在我们已经完成了 C++ 侧的设置,可以开始 Python 侧的编写。这并没有什么特别有用的,但足以展示如何从 Numba 获取 GPU 上的数据并通过我们的共享库将其传递给 libcudf,作为概念验证:
处理导入
首先,让我们处理导入:
>>> import numba.cuda
>>> import pyarrow as pa
>>> from pyarrow import cuda
>>> import numpy as np
>>> from pyarrow.cffi import ffi
在这个示例中,我们使用 PyArrow,但如果你有 ArrowDeviceArray 结构体的定义,你可以创建并填充结构,而无需直接包含或链接 Arrow 库!
我们从 pyarrow.cffi 导入的 ffi 模块已经包含我们所需的结构定义,因此我们只需定义我们的外部函数并加载 C++ 库。之后,我们可以使用 lib.get_sum 调用我们的函数:
>>> ffi.cdef("""
void get_sum(struct ArrowSchema*,
struct ArrowDeviceArray*,
struct ArrowSchema*,
struct ArrowDeviceArray*);""")
>>> lib = ffi.dlopen("./build/libget-sum.so")
现在,让我们看看我们的示例数据。我将使用一个简单的整数列表,将其通过 numba.cuda 复制到 GPU,但这可以替换为任何在 GPU 上计算的内容:
>>> arr = np.arange(10, 14, dtype=np.int32)
>>> arr
array([10, 11, 12, 13], dtype=int32)
>>> device_arr = numba.cuda.to_device(arr)
在我写这段代码的时候,不幸的是,暂时没有直接从 Numba 转换为 PyArrow 数组的方法。然而,我们可以轻松获取 GPU 缓冲区并从中构造数组:
>>> cuda_buf = cuda.CudaBuffer.from_numba(
device_arr.gpu_data)
>>> arrow_arr = pa.Array.from_buffers(
pa.int32(), 4, [None, cuda_buf], null_count=0)
由于我们知道长度(4)和类型(int32),创建数组是微不足道的。没有空值,我们可以将有效性缓冲区留为 None,只需提供第二个缓冲区,即数据。
好了,我们有了一个 Arrow 数组,其数据缓冲区位于 GPU 上。让我们使用 ffi 创建我们的零复制 ArrowDeviceArray 结构体,以便在 Python 中与 C++ 进行交互。请注意,生成的结构体的 device_type 值为 2,这是 ARROW_DEVICE_CUDA 的值:
>>> c_array = ffi.new("struct ArrowDeviceArray*")
>>> c_schema = ffi.new("struct ArrowSchema*")
>>> ptr_array = int(ffi.cast("uintptr_t", c_array))
>>> ptr_schema = int(ffi.cast("uintptr_t", c_schema))
>>> arrow_arr._export_to_c_device(ptr_array,
ptr_schema)
>>> c_array.device_type
2
>>> ffi.string(c_schema.format)
b'i'
最后,我们可以调用我们的函数!我们只需首先创建我们的输出结构体:
>>> out_schema = ffi.new("struct ArrowSchema*")
>>> out_arr = ffi.new("struct ArrowDeviceArray*")
>>> lib.get_sum(c_schema, c_array, out_schema,
out_arr)
>>> ptr_out = int(ffi.cast("uintptr_t", out_arr))
>>> ptr_out_schema = int(ffi.cast("uintptr_t", out_schema))
>>> result = pa.Array._import_from_c_device(ptr_out,
ptr_out_schema)
希望尽管前面的示例比较简单,但它能很好地证明通过利用这个接口可以完成的事情。如果你直接使用 C++ Arrow 库,我们还提供了一系列用于与这些结构体交互并直接在 C++ 数组和模式对象之间转换的辅助函数!因此,如果你感兴趣并想尝试它们,请确保查看文档。
随着设备接口被各种库和工具采纳,我们将看到越来越多直接和明确的帮助程序出现。请持续关注 Arrow 网站和文档,以便在新版本发布时查看公告!
其他用例
除了提供一个用于在组件之间实现零复制共享 Arrow 数据的接口外,C 数据 API 还可以用于在直接依赖 Arrow 库不可行的情况下。
尽管许多语言和运行时都实现了 Arrow,但仍然存在一些没有 Arrow 实现的语言或环境。这在拥有大量遗留软件和/或专用环境的组织中特别常见。一个很好的例子是,在星体和星系的天体物理建模中,主流编程语言仍然是 Fortran!不出所料,Fortran 并没有现有的 Arrow 实现。在这种情况下,通常不太可行的是重写整个代码库,以便你可以在受支持的语言中利用 Arrow。然而,借助 C 数据 API,可以将数据从受支持的运行时共享到现有的不受支持的代码库。或者,你可以反向操作,轻松构建 C 数据结构,以将数据传递给期望 Arrow 数据的组件。
还有许多其他情况下,紧密集成和直接依赖 Arrow 库可能不是最佳选择。以下是一些可能的例子:
- 资源严重受限的环境,例如嵌入式软件开发,就是这样一个例子。尽管有可用的模块化,Arrow 核心库的最小构建在我的系统上约为 15 MB。这看起来可能不算太多,但对于嵌入式开发,即使这样也可能太大。
- Arrow 库发展迅速且频繁更新。有些项目可能无法依赖于像 Arrow 这样快速发展的项目,并确保它们保持最新和更新。
- 开发可能在用户无法因权限或其他限制手动安装外部库(如 Arrow)的环境中进行。
- 你的环境利用静态构建的组件,其中两个或多个组件使用 Arrow 进行通信。通过利用 C 数据 API,这些库可以进行通信,而不要求所有这些组件使用相同版本的 Arrow 依赖项。
C 数据 API 的存在使得消费应用程序和库能够处理 Arrow 内存,而无需直接依赖于 Arrow 库或构建自己的集成。它们可以选择与 Arrow 项目紧密耦合,或仅与 Arrow 格式紧密耦合。我们将在后面的章节中介绍一个很好的例子:在从 DuckDB 查询时使用 Arrow(DuckDB)。
一些练习
在继续之前,看看你能否想出任何其他用例,使用 C 数据 API 在单个进程中实现语言或运行时之间的通信,并草拟简单的实现或大致的设置方案。你可以使用 Arrow 库来导入/导出数据,或者从一种数据格式构建适配器,以便轻松返回 Arrow 格式的数据。
考虑这个——你有一个数据库引擎,想要添加一个选项,将结果以 Arrow 格式的数据传递,但你不想通过直接依赖 Arrow 库来强加新的依赖关系。你如何在不链接 Arrow 库和包含 Arrow C++ 头文件的情况下提供这一点?API 看起来会是什么样子?
也许尝试反转我们一些导入/导出示例的方向,将数据传递给 Python、C++ 或其他技术、语言或运行时的组合。发挥你的创造力!
当然,我已经多次提到 Arrow 提供高速分析能力,但到目前为止,我们主要集中在获取数据和移动数据上。然而,当涉及到对 Arrow 数据实际进行计算时,你并不是自己单打独斗。Arrow 库提供了一个计算 API,可用于构建复杂的查询和计算引擎。
总结
在这次对 Arrow 库的探讨中,我们探索了通过 Arrow C 数据接口在库之间高效共享数据。我们涵盖了用于在系统之间传递数据的各种结构,并通过各种代码示例展示了如何实现这一点,包括在非 CPU 设备上利用数据。请记住,这个接口的动机是为了实现同一运行进程的组件之间的零复制数据共享。它并不是为了让 C 数据 API 模仿 C++ 或 Python 等高级语言中的功能——而只是为了共享数据。此外,如果你在不同进程之间共享,或需要持久存储,则应使用我们在第 3 章《格式和内存处理》中介绍的 Arrow IPC 格式。目前,我们已经涵盖了许多读取、写入和传输 Arrow 数据的方法。然而,一旦你将数据放入内存中,你就会想要对其进行操作,并利用内存分析的好处。
在第 5 章《Acero:流式 Arrow 执行引擎》中,我们将讨论……嗯,计算库!Arrow 库包括一个名为 Acero 的执行引擎,其中实现了大量常见的数学运算(如求和、平均值、中位数等)以及关系运算(如连接和排序),供你轻松使用。
那么,开始吧!