我们已经翻阅了这本书将近一半的内容,但直到现在才开始介绍如何直接使用 Arrow 进行分析计算。这有点奇怪,对吧?不过此时,如果你一直在跟随书中的内容,你应该已经对所有需要掌握的概念有了扎实的理解,从而能够从计算库中受益。
Arrow 社区构建了一个名为 Acero 的开源计算和查询引擎,它基于 Arrow 格式的参考实现。为此,Acero 库的存在是为了简化在 Arrow 格式化数据上运行的各种高性能函数的实现,同时构建用于处理数据流的执行计划。这些操作可能包括在不同数据类型之间进行逻辑转换,也可能用于执行大规模的计算和过滤操作,涵盖了各种可能的任务。与其让用户重复实现这些操作,Acero 提供了一种基于 Arrow 格式的通用高性能实现,许多用户都可以使用这些实现。
本章将教授以下内容:
- 如何使用 Acero 库执行简单的计算
- 在计算时如何使用数据和标量(datum 和 scalar)
- 如何构建和使用执行计划来分析大规模数据流
- 何时以及为什么选择 Acero,而不是自己实现功能
技术要求
本章依旧是高度技术性的,包含了多种代码示例和练习。因此,和之前一样,你需要能够访问一台安装了以下软件的计算机才能跟随内容:
- Python 3.8 及以上版本;必须安装并能够导入 pyarrow 模块
- 支持 C++17 或更高版本的 C++ 编译器
- 已安装 Go 1.21 或更高版本
- 你喜欢的 IDE:Sublime、VS Code、Emacs 等
- 本书 GitHub 仓库中的示例代码和数据,网址为:github.com/PacktPublis…
让 Acero 为你完成工作
在讨论执行计划之前,使用 Acero 时需要理解三个基本概念:
- 输入整形:调用函数时描述输入的数据形状。
- 值转换:确保调用函数时参数之间的数据类型兼容。
- 函数类型:你需要哪种类型的函数?是标量函数、聚合函数,还是向量函数?
我们将快速深入探讨每个概念,以便你了解它们如何影响编写用于计算的代码。
重要提示!
并非所有编程语言的 Arrow 库都提供 Compute API。目前主要暴露这一 API 的是 C++、Python 和 R 库,而在其他语言中的计算库支持程度有所不同。例如,Go 的实现包含一个计算包,但没有 C++、Python 和 R 库中的所有函数。你可以考虑使用 Arrow C 数据接口将数据高效传递到 C++ 进行 Acero 计算,然后再将结果传回没有计算函数实现的环境或语言中。
输入整形
要使计算函数有用,你需要能够提供一个可操作的输入。有时函数需要一个数组,其他时候则需要一个标量值。为此,Acero 提供了一个通用的 Datum
类来表示这些输入可能采用的不同形状。Datum
可以是标量、数组、分块数组,甚至可以是整个记录批或表。每个函数都会定义它允许或需要的输入形状。例如,sort_indices
计算函数需要单一输入,该输入必须是一个数组。
值转换
不同的函数可能要求参数的精确类型才能正确执行和操作。因此,许多函数定义了隐式的类型转换行为,以便于使用。例如,进行加法等算术计算时,要求参数具有相同的数据类型。如果调用加法函数时,提供了一个32位整数数组和一个16位整数数组,Acero 可能会将第二个参数提升并将数组转换为32位整数数组,然后再进行计算,而不是直接报错。
另一个可能应用的例子是字典编码数组。目前实现的计算函数并不直接支持字典编码数组的比较,但许多函数会隐式地将数组转换为可操作的解码形式,而不会抛出错误。
Acero 中的函数类型
在 Acero 中,函数一般分为三类。我们来逐一讨论它们。
聚合函数
聚合函数作用于数组(可选地分块)或标量值,并将输入简化为标量输出值。这可能是单一值,也可能是包含多个值的 StructScalar
值。常见的聚合函数有 count
(计数)、min
(最小值)、mean
(均值)和 sum
(求和)。见图 5.1:
还有分组聚合函数,它们相当于 SQL 风格的 GROUP BY
操作。与对输入的所有值进行操作不同,分组聚合函数首先根据指定的键列进行分区,然后为输入中的每个组输出一个单一的标量值。
一些例子包括 hash_max
、hash_product
和 hash_any
。
元素级或标量函数
这一类别的函数是单输入或一元函数,它们对输入的每个元素分别进行操作。标量输入会产生标量输出,数组输入则会产生数组输出。
一些例子包括 abs
(取绝对值)、negate
(取相反数)和 round
(四舍五入)。见图 5.2:
这一类别中的二元函数对输入的对齐元素进行操作,例如对两个数组进行相加。两个标量输入会产生一个标量输出,而两个数组输入必须具有相同的长度,并会产生一个数组输出。如果提供的是一个标量和一个数组输入,操作会将标量视为与另一个输入具有相同长度 N 的数组,值会被重复使用。
一些例子包括 add
(加法)、multiply
(乘法)、shift_left
(左移)和 equal
(等于)。
数组级或向量函数
这一类别的函数对整个数组进行操作,通常会执行转换,或者输出与输入数组长度不同的结果。见图 5.3。
一些例子包括 unique
(去重)、filter
(过滤)和 sort_indices
(排序索引)。
现在我们已经解释了这些概念,让我们学习如何在一些数据上调用计算函数!
调用函数
Arrow 计算库有一个全局的 FunctionRegistry
对象,它允许根据函数名称查找函数并列出可以调用的函数。可用的计算函数列表也可以在 Arrow 文档中找到,地址为:arrow.apache.org/docs/cpp/co…
使用 C++ 计算库
计算库作为基础 Arrow 包中的一个单独模块进行管理。如果你是从源代码自行编译库,请确保在配置过程中使用了 ARROW_COMPUTE=ON
选项。
注意
这里涉及两个库:Arrow Compute 和 Acero。计算函数、函数注册表和表达式类都存在于计算库中,而 Acero 包含执行计划和执行节点的实现。
示例 1 – 向数组中添加标量值
我们的第一个示例是对一个数据数组进行简单的标量函数调用,使用与之前在 C 数据 API 示例中相同的 Parquet 文件:
首先,我们需要从 Parquet 文件中读取所需的列。我们可以使用 Parquet C++ 库打开文件,并提供一个适配器,直接将 Parquet 对象转换为 Arrow 对象:
#include <arrow/io/file.h>
#include <arrow/status.h>
#include <parquet/arrow/reader.h>
...
constexpr auto filepath = "<path to file>/yellow_tripdata_2015-01.parquet";
auto input = arrow::io::ReadableFile::Open(filepath).ValueOrDie();
std::unique_ptr<parquet::arrow::FileReader> reader;
arrow::Status st = parquet::arrow::OpenFile(input,
arrow::default_memory_pool(),
&reader);
if (!st.ok()) {
// 处理错误
}
打开文件后,最简单的解决方案是将整个文件读取到内存中作为一个 Arrow 表,我们在此将采取这种方式。或者,你也可以使用 ReadColumn
函数仅从文件中读取某一列:
std::shared_ptr<arrow::Table> table;
st = reader->ReadTable(&table);
if (!st.ok()) {
// 处理错误
}
std::shared_ptr<arrow::ChunkedArray> column =
table->GetColumnByName("total_amount");
std::cout << column->ToString() << std::endl;
现在我们已经得到了所需的列,让我们向数组中的每个元素添加值 5.5。由于加法是一个可交换的操作,传递标量和列对象的顺序并不重要。如果我们执行的是减法,顺序则会影响结果:
#include <arrow/compute/api.h>
#include <arrow/scalar.h>
#include <arrow/datum.h>
...
arrow::Datum incremented = arrow::compute::CallFunction("add",
{column, arrow::MakeScalar(5.5)}).ValueOrDie();
std::shared_ptr<arrow::ChunkedArray> output =
std::move(incremented).chunked_array();
std::cout << output->ToString() << std::endl;
最后,我们只需要编译代码。和之前一样,我们将使用 pkg-config
在命令行中生成编译器和链接器标志,使编译过程更加简单:
$ g++ compute_functions.cc -o example1 $(
pkg-config --cflags --libs parquet arrow-compute)
运行可执行文件后,我们可以看到输出,确认操作是否成功。在前面的代码片段中,我们在将标量值 5.5 添加到数据列之前和之后分别打印了该列的内容,直接验证了操作的效果:
$ ./example1
[[ 17.05, 17.8, 10.8, 4.8, <values removed for brevity>]
]
[[ 22.55, 23.3, 16.3, 10.3, <values removed for brevity>]
]
注意
在代码片段中,我们使用了 arrow::ChunkedArray
而不是 arrow::Array
。还记得我们在第 2 章“处理关键的 Arrow 规范”中讨论的分块数组吗?由于 Parquet 文件可以拆分为多个行组,我们可以通过使用分块数组来将一个或多个独立的数组视为一个连续的数组,而无需复制数据。Arrow 表对象允许每个列也可以是分块的,并且每列的分块大小可能不同。并非所有的计算函数都能同时操作分块和非分块数组,但大多数可以。如果输入是一个分块数组,输出也将是分块的。
在前面的示例中,我们使用了 arrow::compute::CallFunction
,但许多函数也提供了便捷的具体 API 可供调用。在这个例子中,我们本可以使用 arrow::compute::Add(column, arrow::MakeScalar(5.5))
。
示例 2 – 最小-最大聚合函数
在我们的第二个示例中,我们将计算文件中 total_amount
列的最小值和最大值。与输出单个标量数值不同,此操作将生成包含两个字段的标量结构。在使用计算函数时,一定要查看文档,以了解返回值的格式。
首先,像之前一样打开 Parquet 文件并获取 total_amount
列。如果有需要,可以参考前一个示例的步骤 1 和 2。相信你能做到。
一些函数(例如 min_max
函数)接受或需要一个选项对象,该对象可以影响函数的行为。返回值是包含 min
和 max
字段的标量结构。我们只需将 Datum
对象转换为 StructScalar
,即可访问它:
arrow::compute::ScalarAggregateOptions scalar_agg_opts;
scalar_agg_opts.skip_nulls = false;
arrow::Datum minmax = arrow::compute::CallFunction(
"min_max",
{column}, &scalar_agg_opts).ValueOrDie();
std::cout <<
minmax.scalar_as<arrow::StructScalar>().ToString()
<< std::endl;
使用和之前相同的编译选项进行编译,运行时应该会得到以下输出:
$ g++ examples.cc -o example2 $(pkg-config --cflags --libs parquet arrow-compute)
$ ./example2
{min:double = -450.3, max:double = 3950611.6}
你得到和我一样的输出了吗?你现在是否已经熟悉了根据名称调用不同的函数,并使用文档来确定数据类型的方式?让我们继续!
示例 3 – 对数据表进行排序
我们的最后一个示例是一个向量计算函数,将整个数据表按 total_amount
列进行排序。
和前两个示例一样,先打开文件并将其读取为 arrow::Table
。
创建排序选项对象,定义我们希望根据哪个列排序表格,以及排序的方向:
arrow::compute::SortOptions sort_opts;
sort_opts.sort_keys = { // 一个或多个键对象
arrow::compute::SortKey{"total_amount",
arrow::compute::SortOrder::Descending}};
arrow::Datum indices = arrow::compute::CallFunction(
"sort_indices", {table},
&sort_opts).ValueOrDie();
sort_indices
函数本身不复制数据;它的输出是表格的行索引数组,这些索引用来定义排序后的顺序。如果我们愿意,可以使用索引数组创建一个新的排序版本的表格,但我们也可以让 Compute API 为我们完成这项工作:
arrow::Datum sorted = arrow::compute::Take(table,
indices).ValueOrDie();
// 或者你可以使用 CallFunction("take", {table, indices})
auto output = std::move(sorted).table();
take
函数的第一个参数接受一个数组、分块数组、记录批或表格,第二个参数则是数值索引的数组。对于索引数组中的每个元素,输出将会包含第一个参数中对应索引处的值。在我们的示例中,它使用生成的排序索引列表来按顺序输出数据。由于我们给出的输入是表格,所以输出也是表格。同样的操作也适用于记录批、数组或分块数组。
试着尝试不同的函数和计算功能,看看输出是什么样的,以及有哪些选项可以用于定制行为,比如在排序时是否应将空值放在开头或结尾。作为练习,尝试按多个键以不同的方向对数据表进行排序,或进行不同的数据转换操作,例如过滤或生成派生计算。接下来,我们将继续使用 Python 进行相同的示例。
在 Python 中使用计算库
计算库作为 pyarrow
模块的一部分也可在 Python 中使用。Python 的语法简洁易用,使得它比 C++ 库更容易操作。许多你可能需要转换为 pandas
DataFrame 或使用 NumPy
的函数,都可以直接在 Arrow 数据上通过计算库进行操作,从而节省了在不同格式之间转换的 CPU 资源。
示例 1 – 向数组添加标量值
与 C++ 示例类似,我们将从纽约出租车数据的 Parquet 文件中向 total_amount
列添加一个标量值。
还记得如何在 Python 中从 Parquet 文件中读取列吗?如果不记得,我会在这里为你回顾一下:
>>> import pyarrow.parquet as pq
>>> filepath = '<path to file>/yellow_tripdata_2015-01.parquet'
>>> tbl = pq.read_table(filepath) # 读取整个文件
>>> tbl = pq.read_table(filepath, columns=['total_amount']) # 只读取一列
>>> column = tbl['total_amount']
pyarrow
库将为你执行许多必要的类型转换,因此使用它与 Compute API 结合非常简单:
>>> import pyarrow.compute as pc
>>> pc.add(column, 5.5)
<pyarrow.lib.ChunkedArray object at 0x7fdd2a3719a0>
[
[
22.55,
23.3,
16.3,
10.3,
...
函数的参数可以是原生 Python 值、pyarrow.Scalar
对象、数组或分块数组。如果需要,库将尽最大努力进行相应的类型转换。
示例 2 – 最小-最大聚合函数
继续学习,我们可以像在 C++ 中那样找到 total_amount
列的最小值和最大值。
从 Parquet 文件中获取数据列,如前一个示例所示。试着自己实现一下,然后对照以下代码片段看看:
>>> pc.min_max(column)
<pyarrow.StructScalar: [('min', -450.3),('max',3950611.6)]>
正如预期的那样,这些值与 C++ 版本计算的结果一致。
示例 3 – 对数据表进行排序
为了完成我们的示例,我们将对 Parquet 文件中的整个数据表进行排序。试着自己动手做一做,然后再看看步骤是否正确:
再次使用 read_table
函数读取整个 Parquet 文件。
准备好了吗?虽然计算库有一个 take
函数,但 Python 库直接在 Table
对象上暴露了该函数,这让操作更加简单:
>>> sort_keys = [('total_amount', 'descending')]
>>> out = tbl.take(pc.sort_indices(tbl, sort_keys=sort_keys))
就这样!排序键的定义与 C++ 类似,是一个由列名和排序方向组成的元组。你也可以传递其他选项,通过使用完整的 pc.SortOptions
对象,并将其作为 options
参数传递给 sort_indices
,而不是使用 sort_keys
。
练习!
作为练习,试着使用 Go 库及其计算包来实现相同的示例!
由于计算库的易用性和便利性,你可能会好奇,直接自己执行简单的计算与使用计算库相比如何。例如,手动编写循环来为 Arrow 数组添加常量更有意义,还是总是使用计算库更好?让我们继续探讨……
选择合适的工具
Arrow 计算库提供了一个非常易于使用的接口,但性能如何呢?它们只是为了易用性而存在的吗?让我们来测试一下并进行比较!
向数组添加常量值
在我们的第一个测试中,我们将尝试向我们构建的示例数组添加一个常量值。这里不需要复杂的设置,因此我们可以创建一个简单的 32 位整数 Arrow 数组,然后向每个元素添加 2,并生成一个新数组。我们将创建不同大小的数组,然后测试使用不同方法将常量 2 添加到 Arrow 数组所需的时间。
提醒!
语义上来说,Arrow 数组是不可变的,因此添加常量会生成一个新数组。不可变性这一特性通常被用于根据特定的 Arrow 实现进行优化和内存的重用。虽然通过就地修改缓冲区可能可以实现更高的性能,但如果你选择这样做,必须小心。确保没有其他 Arrow 数组对象共享相同的内存缓冲区,否则你可能会遇到问题。
首先,让我们创建测试数组:
#include <numeric> // 用于 std::iota
std::vector<int32_t> testvalues(N);
std::iota(std::begin(testvalues), std::end(testvalues), 0);
arrow::Int32Builder num_bldr;
num_bldr.AppendValues(testvalues);
std::shared_ptr<arrow::Array> numarr;
num_bldr.Finish(&numarr);
auto arr = std::static_pointer_cast<arrow::Int32Array>(numarr);
很简单,对吧?在第一行中,我们使用 std::iota
将向量填充为从 0 到 N 的值。接着,我们将这些值附加到 Int32Builder
对象中,生成我们的测试数组。
我们将测试以下四种情况:
- 使用
arrow::compute::Add
函数 - 使用
arrow::Int32Builder
的简单for
循环 - 使用
std::for_each
和arrow::Int32Builder
,并调用Reserve
预分配内存 - 将原始缓冲区视为 C 风格数组,预分配一个新缓冲区,并从缓冲区构造结果 Arrow 数组
使用 Compute Add
函数
这是最简单的情况,展示了计算库的易用性。你可以先自己尝试一下,再看看代码片段:
arrow::Datum res1;
{
timer t;
res1 = cp::Add(arr, arrow::Datum{(int32_t)2}).MoveValueUnsafe();
}
如何?简洁明了!我们在作用域外声明了 arrow::Datum
以便保存结果,并验证其他方法生成的结果是否相同。
使用简单的 for
循环
在这种情况下,我们将创建一个 Int32Builder
对象并遍历数组,调用 Append
和 AppendNull
。这是一个简单的解决方案,如果要求将常量添加到 Arrow 数组,很多人可能会想出这样的办法:
arrow::Datum res2;
{
timer t;
arrow::Int32Builder bldr;
for (size_t i = 0; i < arr->length(); ++i) {
if (arr->IsValid(i)) {
bldr.Append(arr->Value(i)+2);
} else {
bldr.AppendNull();
}
}
std::shared_ptr<arrow::Array> output;
bldr.Finish(&output);
res2 = arrow::Datum{std::move(output)};
}
std::cout << std::boolalpha << (res1 == res2) << std::endl;
还不错,对吧?如果生成的结果与之前的结果相同,最后一行将输出 true
,否则输出 false
。
使用 std::for_each
并预分配空间
此实现主要是对前面 for
循环解决方案的变体,添加了内存的预分配。我们将使用 std::for_each
和 lambda 函数来实现。需要添加几个额外的头文件:
#include <arrow/util/optional.h>
#include <algorithm>
代码实现如下:
arrow::Datum res3;
{
timer t;
arrow::Int32Builder bldr;
bldr.Reserve(arr->length());
std::for_each(std::begin(*arr), std::end(*arr),
[&bldr](const arrow::util::optional<int32_t>& v) {
if (v) { bldr.Append(*v + 2); }
else { bldr.AppendNull(); }
});
std::shared_ptr<arrow::Array> output;
bldr.Finish(&output);
res3 = arrow::Datum{std::move(output)};
}
std::cout << std::boolalpha << (res1 == res3) << std::endl;
请注意,高亮的部分是与之前的 for
循环不同的地方。这里,我们利用了 Arrow 数组提供的 STL 兼容迭代器,值类型为 arrow::util::optional<T>
。通过解引用迭代器,可以直接获取值。
分而治之
最后一个实现利用了一些 Arrow 规范的特性:
- 对于空值索引,原始数据缓冲区中的值可以是任何内容。
- 一个基本的数值数组包含两个缓冲区:一个位图和一个包含原始数据的缓冲区。
- 当向 Arrow 数组添加常量时,结果数组的空值位图与原始数组相同。
基于这些前提,我们可以分别处理数组的空值位图和数据缓冲区。
代码如下:
arrow::Datum res4;
{
timer t;
std::shared_ptr<arrow::Buffer> new_buffer =
arrow::AllocateBuffer(sizeof(int32_t) * arr->length()).MoveValueUnsafe();
auto output = reinterpret_cast<int32_t*>(new_buffer->mutable_data());
std::transform(arr->raw_values(),
arr->raw_values() + arr->length(),
output,
[](const int32_t v) {
return v + 2;
});
res4 = arrow::Datum{arrow::MakeArray(
arrow::ArrayData::Make(
arr->type(), arr->length(),
std::vector<std::shared_ptr<arrow::Buffer>>{
arr->null_bitmap(), new_buffer},
arr->null_count()))};
}
std::cout << std::boolalpha << (res1 == res4) << std::endl;
理解了吗?如果不完全理解,可以参考图 5.4。
你可以在图 5.4 中看到每个变量在图中的位置, 希望它能够清晰展示我们是如何将两个缓冲区分别处理并创建新的数组的。
现在,真相时刻到了。让我们比较这些方法的性能!图 5.5 是一个展示各个方法性能的图表:
为了创建这个图表,我通过将每种方法放入循环中多次运行,并将总耗时除以迭代次数来平滑任何异常值。这是一种标准的基准测试方法。从结果中可以清楚地看出,计算库不仅提供了易于使用的接口,还表现出极高的性能。在我们尝试的不同方法中,唯一能与直接调用计算库的 Add
函数相媲美的就是我们的“分而治之”方法。这很合理,因为这正是计算库所采用的底层策略!
这在处理单个数据表时表现出色,但大多数现代数据集并不那么简单。当你处理包含多个文件的表格数据集时,你将需要一些额外的工具。比如一个查询引擎和执行计划?这正是 Acero 大显身手的地方!
总是要有计划
由于 Arrow 格式的列式结构,以及有效性位图与数据缓冲区的分离,很容易理解为什么它在系统间互操作时非常受欢迎。一些查询引擎(如 InfluxDB)选择了 Arrow 作为其内部格式,因为在执行计算时它效率极高。其他系统,如 DuckDB 和 Velox,则采用了与 Arrow 几乎相同的内部表示方式,从而实现了与生态系统的零拷贝交互。我们将在后面的章节中详细讨论这些项目,但现在我们要探讨 Acero,这是一个以 Arrow 作为其内部数据表示的执行引擎的参考实现。
我想特别强调的是,大多数情况下,虽然计算函数非常有用,但 Acero 并不是为数据科学家直接使用而设计的。通常,用户会使用某种前端(例如 pandas、Ibis 或 SQL 解析器),这些前端会生成执行计划并传递给 Acero。不过,本书的目标是为那些想从头构建数据系统的人提供帮助!另外,一些用户可能只是对其使用的某些库的后台处理原理感兴趣。接下来,让我们深入了解一下……
Acero 的定位
Arrow C++ 库中的 Acero 模块是一个依赖于 libarrow
和 libarrow_compute
的独立模块,但它不是一个单独的库。核心 libarrow
库提供了缓冲区和数组的容器及高级抽象,这些容器和数组按照 Arrow 格式布局在内存中。如果你想对数据进行修改,比如将字符串数组中的小写字母转换为大写字母,就可以使用计算库。
我们在前几节中使用的函数都是通过一个函数注册表提供的。函数接受零个或多个 Arrow 数组、记录批或表,并生成一个数组、记录批或表作为结果。在之前的示例中,我们直接调用了这些函数并传递参数。你也可以组合函数调用、字段引用和字面值,形成一个表达式(一个由函数调用组成的树)。例如,假设我们有一个包含两列 x
和 y
的表,想要计算表达式 x + (y * 3)
。图 5.6 展示了如何将其表示为一个表达式。
Acero 在此基础上进一步扩展,允许计算操作在数据流上运行。为此,需要描述一个执行计划,然后将该计划的每个节点应用到一批批数据流中。复杂的执行计划可以组合成一个图,就像创建表达式树一样。我们来看一个图 5.7 中的小示例。
我们现在来逐步解析这个图表:
- 扫描节点(Scan node) :定义了一个用于扫描包含两列的数据集的节点。
- 过滤节点(Filter node) :用于通过计算表达式筛选出符合条件的行,该表达式返回一个布尔值。
- 投影节点(Project node) :向输出中添加一个新列,该列的值是基于另一个列的值计算得出的。
你会注意到,在图中,过滤节点和投影节点都使用了计算表达式,因此在执行图中有一个子树,代表了表达式树。
在进入代码示例之前,我们将介绍 Acero 在定义计划时使用的一些基本概念。
Acero 的核心概念
在 Acero 中,一切都是从四种基本对象类型派生出来的:
- ExecNode
- ExecBatch
- ExecPlan
- Declaration
如果你理解了这些对象及其交互方式,那么你就会对 Acero 的执行方式有一个扎实的理解。之后,我会为你提供一个有趣的代码示例来玩玩!让我们深入了解这些概念吧……
ExecNode
ExecNode
是 Acero 工作原理中最简单的概念:它是计划中的一个节点,包含零个或多个输入和零个或一个输出。简单吧?你可以将这些节点视为指令节点,它们告诉计划如何处理数据。按照图论的通用理论,一个没有输入的 ExecNode
是源节点(source),一个没有输出的 ExecNode
是汇节点(sink)。各种不同类型的节点会以不同的方式转换它们的输入。
注意 可用节点的完整列表可以在 C++ Arrow 文档的 Acero 用户指南中找到,访问这些节点的 URL 为:arrow.apache.org/docs/cpp/ac…
以下是一些常见的节点示例:
- 扫描节点(Scan node) :一个源节点,用于从文件中读取数据(文件可以是本地的或远程的)。
- 聚合节点(Aggregate node) :一个节点,用于累积数据批次,并对数据进行列级计算(参见图 5.1)。
- 过滤节点(Filter node) :根据过滤表达式从传入的批次中移除行(参见图 5.3)。
- 表汇节点(Table sink node) :将数据累积成表。
- 投影节点(Project node) :选择要传递到下一个节点或输出的列,或者通过定义表达式为它们添加新计算的列并命名该列。
ExecBatch
与我们熟悉的 RecordBatch
对象不同,Acero 使用一种类似的结构 ExecBatch
来表示流数据批次。为什么会这样呢?回顾一下图 5.7,两个计算表达式都使用了字面值:
(x + 4) > 20
使用了4
和20
。y + 2
使用了2
。
在 RecordBatch
实例中,若要表示这些字面整数,我们需要一个与每个数据批次长度相同的 Arrow 数组,其中每一行的值等于这个字面值。而在 ExecBatch
对象中,每一列可以是一个数组或标量,从而在处理这些常量时节省内存。此外,还有以下不同之处:
- ExecBatch 对象不包含模式(schema) 。每个批次都被视为数据流的一部分,并且假设数据流有一个一致的模式。因此,每个批次的预期模式通常存储在处理该批次的
ExecNode
对象中。 - ExecBatch 对象中通常包含额外的元数据,例如描述批次在有序流中的位置的索引或其他有用的字段。
图 5.8 展示了如何通过 ExecBatch
实例利用数组和标量来表示潜在的数据表。
从 RecordBatch
对象转换为 ExecBatch
对象始终是零拷贝操作。两者都引用完全相同的底层数据缓冲区。反过来,如果 ExecBatch
实例中没有标量,转换回 RecordBatch
也可以是零拷贝的。
ExecPlan
ExecPlan
是由一组 ExecNode
对象组成的图。一个有效的 ExecPlan
对象必须至少有一个源节点(source node),但不一定需要有汇节点(sink node)。这个对象还包含所有节点共享的资源,以及用于控制节点生命周期和执行的工具。需要注意的是,ExecPlan
对象是有状态的!ExecPlan
对象及其节点的生命周期与一次执行紧密相关,不能重新启动或复用。
由于 ExecPlan
对象非常复杂,定义起来也比较繁琐,因此引入了我们最后一个对象类型:Declaration。
Declaration
你可以将 Declaration
对象视为创建 ExecNode
实例的蓝图。通过将一组 Declaration
实例组合成一个图,就创建了一个完整的 ExecPlan
对象的蓝图。事实上,除非你在 Acero 库内部工作,否则你几乎不会直接操作 ExecPlan
对象,而是应该使用 Declaration
辅助工具来定义它们。
通常,Declaration
实例需要与其他查询表示(如 SQL 或 Substrait)进行转换。因此,Declaration
是 Acero 的当前公共 API,并提供了将其转换为其他表示类型的方法。图 5.9 展示了 Declaration
实例与 ExecPlan
对象之间关系的可视化表示。
好的,信息量很大,你还跟得上吗?现在是写代码的时间!
开始流式处理!
首先,我们需要一些数据。在本书 GitHub 仓库的 sample_data
目录中,你会找到一个名为 starwars.parquet
的文件。这个文件的数据最初来自 swapi.py4e.com,这是一个用于检索《星球大战》电影中角色数据的 REST API。然后这些数据被增强后用于 RStudio 和 dplyr,托管在 dplyr.tidyverse.org/reference/s…。现在我们准备好了,来点有趣的东西吧……
首先是必要的包含和一些准备工作:
#include <arrow/acero/api.h> // 计划和节点
#include <arrow/compute/api.h> // 字段引用和表达式
#include <arrow/io/api.h> // ReadableFile
#include <arrow/table.h>
#include <arrow/result.h>
#include <arrow/status.h>
#include <parquet/arrow/reader.h>
namespace aio = ::arrow::io;
namespace cp = ::arrow::compute;
namespace ac = ::arrow::acero;
这些命名空间声明会让代码更易于阅读。请注意,我们还包含了来自 Parquet 库的头文件(你可以通过在构建 Arrow C++ 库时确保传递 ARROW_PARQUET=ON
来构建,或者安装 libparquet
)。现在我们可以编写一个简单的小示例来读取文件中的几列数据。完整代码可以在 GitHub 仓库中找到。
提醒!
还记得之前代码示例中的 ARROW_ASSIGN_OR_RAISE
和 ARROW_RETURN_NOT_OK
宏吗?这些宏用于检查 Arrow 函数的结果和/或状态,如果它们失败,则返回一个合适的 arrow::Status
对象。
首先,我们打开 Parquet 文件并创建一个可以生成 Arrow 记录批次的读取器:
arrow::Status simple_acero(std::string path) {
auto* pool = arrow::default_memory_pool();
ARROW_ASSIGN_OR_RAISE(auto input,
aio::ReadableFile::Open(path));
std::unique_ptr<parquet::arrow::FileReader> arrow_reader;
ARROW_RETURN_NOT_OK(parquet::arrow::OpenFile(input, pool, &arrow_reader));
...
}
这些高亮的代码首先使用 Arrow IO 模块打开文件,然后创建一个 Parquet FileReader
对象,我们可以用它来创建记录批次。接下来,我们需要从 FileReader
对象获取一个 RecordBatchReader
实例:
std::unique_ptr<arrow::RecordBatchReader> rdr;
ARROW_RETURN_NOT_OK(arrow_reader->GetRecordBatchReader(&rdr));
现在文件读取已经完成,我们可以继续完善我们的函数:
arrow::Status simple_acero(std::string path) {
...
ac::Declaration reader_source(
"record_batch_reader_source",
ac::RecordBatchReaderSourceNodeOptions{std::move(rdr)}};
ac::Declaration project{"project",
{std::move(reader_source}},
ac::ProjectNodeOptions({cp::field_ref("name"),
cp::field_ref("species"),
cp::field_ref("homeworld")})};
ARROW_ASSIGN_OR_RAISE(auto result,
ac::DeclarationToTable(std::move(project)));
std::cout << "Results: " << result->ToString() << std::endl;
return arrow::Status::OK();
}
这些高亮的代码展示了使用 Declaration
对象的简便性。我们完成了以下操作:
- 使用
RecordBatchReader
对象声明了源节点。 - 使用
Project
节点传递字段引用,定义我们希望输出的列。 DeclarationToTable
执行计划,将结果累积为arrow::Table
对象,我们可以将其输出为字符串。
编译示例与之前一样简单:
$ g++ simple_acero.cc $(pkg-config --cflags --libs arrow-acero parquet) -o acero_example
当然,如果你无法轻松访问 pkg-config
,可以用适当的编译标志替代,例如 -I<arrow include目录> -L<arrow和parquet库路径> -larrow_acero -larrow -lparquet
。运行生成的可执行文件,输出应类似于:
Results: name: string
species: string
homeworld: string
----
name:
[
[
"Luke Skywalker",
"C-3PO",
...
]
]
species:
[
[
"Human",
"Droid",
...
]
]
homeworld:
[
[
"Tatooine",
"Tatooine",
...
]
]
这就是结果!
简化复杂性
我们之前的示例是从文件中读取并返回几列数据的非常简单的案例。通过扩展计划,我们可以执行数据的聚合和转换。接下来,我们将替换 Project
节点,用 Aggregate
节点按 homeworld
列对角色进行分组。
arrow::Status complex_plan(std::string path) {
// 和之前一样的代码来打开 Parquet 文件
...
ac::Declaration reader_source{
"record_batch_reader_source",
ac::RecordBatchReaderSourceNodeOptions{std::move(rdr)}};
ac::Declaration agg_plan{"aggregate"
{std::move(reader_source)},
ac::AggregateNodeOptions({
{{"hash_list", nullptr, "name", "name_list"}}},
{"homeworld"})};
ARROW_ASSIGN_OR_RAISE(auto result,
ac::DeclarationToTable(std::move(agg_plan)));
std::cout << "Results: " << result->ToString() << std::endl;
return arrow::Status::OK();
}
使用 aggregate
节点时,它被视为“管道断点”。这意味着它会强制 Acero 完全将数据集物化到内存中,然后再继续执行计划中的其他节点。对于我们相对较小的 Parquet 文件来说,这不是问题,但对于更大的数据集,这可能会成为问题,可能需要其他解决方案来执行聚合。然而,这超出了我们的讨论范围。现在,我们来可视化我们创建的计划——请看图 5.10。
很简单,对吧?结果是什么样的呢?
Results:
homeworld: string
name_list: list<item: string>
child 0, item: string
----
homeworld:
[
[
"Tatooine",
"Naboo",
...
]
]
name_list:
[
[
[
"Luke Skywalker",
...
],
[
"R2-D2",
...
],
...
]
]
我们成功地将每个家乡的角色通过 List
列分组,正如我们预期的那样。太棒了!不过代码有点冗长,能简化一下吗?如果你看一下 Declaration
语句,我们将前一个声明(reader_source
)作为输入传递给下一个。如果计划足够大且复杂,包含多个输入,代码可能会变得难以维护。为了解决这个问题,Acero 提供了一个名为 Sequence
的辅助工具,用于简化将前一个 Declaration
对象作为下一个输入的场景。我们可以将之前的代码改写为使用 Sequence
,如下所示:
auto plan = ac::Declaration::Sequence(
{{"record_batch_reader_source",
ac::RecordBatchReaderSourceNodeOptions{
std::move(rdr)}},
{"aggregate", ac::AggregateNodeOptions({{
{"hash_list", nullptr, "name",
"name_list"}}},{"homeworld"})}});
这样怎么样?现在我们可以轻松地为数据添加其他聚合操作:
- 将每个角色的种族信息汇总为相应的列表列
- 计算每个独特家乡值的角色的平均身高
如下所示,突出显示新增的行:
auto plan = ac::Declaration::Sequence(
{{"record_batch_reader_source",
ac::RecordBatchReaderSourceNodeOptions{
std::move(rdr)}},
{"aggregate", ac::AggregateNodeOptions({{
{"hash_list", nullptr, "name", "name_list"},
{"hash_list", nullptr, "species",
"species_list"},
{"hash_mean", nullptr, "height",
"avg_height"}}},
{"homeworld"})}});
这会为我们的输出添加两个新列:species_list
和 avg_height
。通过对 height
列使用 hash_mean
函数,而不是 hash_list
函数,我们的计划将计算组的平均值,而不是仅将值汇总为列表。最后,我们可以在计划中添加额外的节点。在最后一个示例中,我们将使用计算表达式定义一个过滤表达式,并对数据进行过滤。
为此,我们需要考虑操作的顺序。我们可以将过滤操作放在聚合之前或之后(参见图 5.11)。如果在聚合之后进行过滤,那么我们可能会做一些额外的工作,即为我们要过滤掉的整组行执行聚合。如果在聚合之前进行过滤,那么这会改变聚合的结果。因此,过滤节点的位置取决于我们按哪些列进行过滤,以及我们希望通过聚合实现的目标。在我们的案例中,我们将按 homeworld
列进行过滤,这个操作应在聚合之前进行,以避免不必要的工作。
幸好,构建过滤节点非常简单明了。我们将从 homeworld
列中选择一些要排除的值,然后使用 is_in
计算函数来声明过滤表达式。我们会使用数组来定义 is_in
表达式的值:
arrow::StringBuilder excl_bldr;
ARROW_RETURN_NOT_OK(excl_bldr.Append("Skako"));
ARROW_RETURN_NOT_OK(excl_bldr.Append("Utapau"));
ARROW_RETURN_NOT_OK(excl_bldr.Append("Nal Hutta"));
std::shared_ptr<arrow::StringArray> exclusions;
ARROW_RETURN_NOT_OK(excl_bldr.Finish(&exclusions));
现在,我们定义过滤表达式:
auto filter_expr = cp::call("invert",
{cp::call("is_in", {cp::field_ref("homeworld")},
cp::SetLookupOptions{
*exclusions})});
与其定义所有我们想保留的值,不如如代码高亮部分显示的那样,我们将 is_in
调用的结果反转。我们定义想排除的值,然后将 is_in
计算的结果取反。现在我们准备好添加过滤节点了:
auto plan = ac::Declaration::Sequence(
{{"record_batch_reader_source",
ac::RecordBatchReaderSourceNodeOptions{
std::move(rdr)}},
{"filter", ac::FilterNodeOptions{
std::move(filter_expr)}},
{"aggregate", ac::AggregateNodeOptions({{
{"hash_list", nullptr, "name", "name_list"},
{"hash_list", nullptr, "species", "species_list"},
{"hash_mean", nullptr, "height", "avg_height"}}},
{"homeworld"})}});
重新编译并运行示例。你是否得到了预期的输出?可以查阅 Arrow C++ 的 Acero 文档,看看其他可用的操作和计算函数。试着创建更复杂的计划,比如多个源、连接等!如果你特别好奇内部实现,或者想创建自己的节点和表达式,文档中有你需要的解释,帮助你学习如何操作。
总结
计算 API 不仅是一个方便的接口,用于对 Arrow 格式化的数据执行函数,而且性能非常出色。库的目标是以易于使用的方式为尽可能多的用例提供高度优化的计算函数。正如我们在排序表和创建 Acero 执行计划的示例中所见,这些函数也高度可组合。
在本章和前一章《第 4 章:使用 Arrow C 数据 API 跨越语言障碍》中,我们探讨了任何分析引擎的构建模块。Arrow C 数据接口和计算 API 在不同的用例中都极为有用,甚至可以一起使用。例如,假设你正在使用 Arrow 的一种语言尚未提供计算 API。通过使用 C 数据 API,你可以高效地将数据从系统的一个组件共享到另一个可以调用 Acero 进行计算的组件,这也是 PyArrow 计算库的工作方式!
如果你正在处理比单台机器可用内存更大的多文件表格数据集,依然可以使用计算库。不仅如此,数据集库还与 Acero 协同工作,执行过滤、下推和投影操作。这就是我们将在第 6 章《使用 Arrow Datasets API》中探讨的内容。数据集库与 Acero 一起可以为你完成读取、选择、过滤和对数据进行流式计算的大量工作。
继续前进!向云端进发!