本文介绍了 Arrow 数据对象的内部结构。Arrow R 包的用户通常不需要了解 Arrow 数据对象的内部结构。我们在这里提供这些信息是为了帮助那些希望了解 Arrow 规范 的 R 用户和 Arrow 开发人员。本文更深入地探讨了 数据对象文章 中描述的一些主题,主要面向开发者。使用 arrow 包不需要了解这些知识。
我们首先描述两个关键概念
- 数组中的值存储在一个或多个**缓冲区**中。缓冲区是一个具有给定长度的连续虚拟地址空间(即内存块)。给定一个指定缓冲区起始内存地址的指针,可以使用一个“偏移量”值来访问缓冲区中的任何字节,该偏移量指定相对于缓冲区起始位置的位置。
- **数组的物理布局**是一个术语,用于描述数组中的数据如何在内存中布局,而不考虑如何解释这些信息。例如:32 位有符号整数和 32 位浮点数具有相同的布局:它们都是 32 位,在内存中表示为 4 个连续的字节。含义不同,但布局相同。
我们可以使用一个简单的整数数组来解释这些概念
integer_array <- Array$create(c(1L, NA, 2L, 4L, 8L))
integer_array
## Array
## <int32>
## [
## 1,
## null,
## 2,
## 4,
## 8
## ]
我们可以检查 integer_array$type
属性以查看数组中的值是如何存储为有符号 32 位整数的。当 Arrow C++ 库将其布局在内存中时,整数数组由两部分元数据和两个存储数据的缓冲区组成。元数据指定数组的长度和空值的个数,两者都存储为 64 位整数。这些元数据可以使用 R 中的 integer_array$length()
和 integer_array$null_count
分别查看。与数组关联的缓冲区数量取决于正在存储的确切数据类型。对于整数数组,有两个:一个“有效位图缓冲区”和一个“数据值缓冲区”。我们可以示意性地将数组描绘如下
此图像将数组显示为一个矩形,细分为两部分,一部分用于元数据,另一部分用于缓冲区。在矩形下方,我们为您解压缩了缓冲区的内容,在虚线包围的区域中显示了两个缓冲区的内容。在图的最底部,您可以看到特定字节的内容。
有效位图缓冲区
有效位图是二进制值的,当数组中对应的槽包含有效值(非空值)时,它包含 1。在抽象级别上,我们可以假设它包含以下五个位
10111
但是,由于三个原因,这有点过于简单化了。首先,由于内存以字节为单位分配,因此末尾有三个尾随位(假定为零),给我们位图 10111000
。其次,虽然我们是从左到右编写的,但这种书写格式通常假定表示 大端格式,其中最高有效位首先写入(即写入到最低值的内存地址)。Arrow 采用小端约定,当用英语书写时,这更自然地对应于从右到左的排序。为了反映这一点,我们从右到左编写位:00011101
。最后,Arrow 鼓励 自然对齐的数据结构,其中分配的内存地址是数据块大小的倍数。Arrow 使用64 字节对齐,因此每个数据结构的大小必须是 64 字节的倍数。此设计特性是为了允许有效地使用现代硬件,如 Arrow 规范 中所述。这就是缓冲区在内存中的样子
字节 0(有效位图) | 字节 1-63 |
---|---|
00011101 |
0 (填充) |
数据缓冲区
数据缓冲区与有效位图一样,也填充到 64 字节的长度以保持自然对齐。以下是显示物理布局的图表
字节 0-3 | 字节 4-7 | 字节 8-11 | 字节 12-15 | 字节 16-19 | 字节 20-63 |
---|---|---|---|---|---|
1 |
未指定 | 2 |
4 |
8 |
未指定 |
每个整数占用 4 个字节,根据 32 位有符号整数的要求。请注意,与缺失值关联的字节未指定:为该值分配了空间,但这些字节未填充。
偏移缓冲区
某些类型的 Arrow 数组包含第三个缓冲区,称为偏移缓冲区。这在字符串数组的上下文中最为常见,例如以下数组
string_array <- Array$create(c("hello", "amazing", "and", "cruel", "world"))
string_array
## Array
## <string>
## [
## "hello",
## "amazing",
## "and",
## "cruel",
## "world"
## ]
使用与之前相同的示意图表示法,这是对象的结构。它具有与之前相同的元数据,但如下所示,现在有三个缓冲区
要理解偏移缓冲区的作用,有助于注意字符串数组的数据缓冲区的格式:它将所有字符串首尾相连地连接到内存的一个连续部分。对于 string_array
对象,数据缓冲区的内容将看起来像一个很长的 utf8 编码字符串
helloamazingandcruelworld
由于单个字符串的长度可变,因此偏移缓冲区的作用是指定槽之间的边界在哪里。我们数组中的第二个槽是字符串 "amazing"
。如果数据数组中的位置按如下方式索引
h | e | l | l | o | a | m | a | z | i | n | g | a | n | d | … |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | … |
那么我们可以看到感兴趣的字符串从位置 5 开始,到位置 11 结束。偏移缓冲区包含存储这些断点位置的整数。对于 string_array
,它可能如下所示
0 5 12 15 20 25
utf8()
数据类型和 large_utf8()
数据类型之间的区别在于,utf8()
数据类型将这些存储为 32 位整数,而 large_utf8()
类型将它们存储为 64 位整数。
分块数组
数组是不可变的对象:一旦初始化了数组,它存储的值就不能更改。这确保了多个实体可以通过指针安全地引用数组,并且不会冒值会更改的风险。使用不可变数组使 Arrow 能够避免不必要的数据对象副本。
不可变数组有一些限制,最显著的是当出现新的数据批次时。因为数组是不可变的,所以您不能将新信息添加到现有数组中。如果您不想干扰或复制现有数组,唯一可以做的事情是创建一个包含新数据的数组。这样做保留了数组的不可变性,并且不会导致任何不必要的复制,但现在我们有了一个新问题:数据被拆分到两个数组中。每个数组只包含数据的“一块”。理想的情况是,有一个抽象层允许我们将这两个数组视为一个“类似数组”的对象。
这就是分块数组解决的问题。分块数组是数组列表的包装器,允许您“如同”它们是一个数组一样索引它们的内容。在物理上,数据仍然存储在不同的位置——每个数组是一块,并且这些块不必在内存中彼此相邻——但分块数组为我们提供了一个抽象层,允许我们假装它们都是一个东西。
为了说明这一点,让我们使用 chunked_array()
函数
chunked_string_array <- chunked_array(
c("hello", "amazing", "and", "cruel", "world"),
c("I", "love", "you")
)
chunked_array()
函数只是 ChunkedArray$create()
提供的功能的包装器。让我们看看这个对象
chunked_string_array
## ChunkedArray
## <string>
## [
## [
## "hello",
## "amazing",
## "and",
## "cruel",
## "world"
## ],
## [
## "I",
## "love",
## "you"
## ]
## ]
此输出中的双括号旨在突出显示分块数组的“列表式”特性。有三个单独的数组,包装在一个容器对象中,该对象实际上是一个数组列表,但允许该列表的行为就像一个常规的一维数据结构一样。示意性地,它看起来像这样
如该图所示,这里确实有三个数组,每个数组都有自己的有效位图、偏移缓冲区和数据缓冲区。
记录批次
记录批次是一种类似表的结构,由一系列数组组成。数组可以是不同的类型,但它们都必须具有相同的长度。每个数组被称为记录批次的“字段”或“列”之一。每个字段必须具有(UTF8 编码的)名称,这些名称构成记录批次的元数据的一部分。当存储在内存中时,记录批次不包含每个字段中存储值的物理存储:而是包含指向相关数组对象的指针。但是,它包含自己的有效位图。
这是一个包含 5 行 3 列的记录批次
rb <- record_batch(
strs = c("hello", "amazing", "and", "cruel", "world"),
ints = c(1L, NA, 2L, 4L, 8L),
dbls = c(1.1, 3.2, 0.2, NA, 11)
)
rb
## RecordBatch
## 5 rows x 3 columns
## $strs <string>
## $ints <int32>
## $dbls <double>
在抽象级别上,rb
对象的行为类似于具有行和列的二维结构,但在内存中表示方式方面,它从根本上来说是一个数组列表,如下所示
表
为了处理矩形数据集随着时间的推移(随着更多数据添加)而增长的情况,我们需要一个类似于记录批次的表格数据结构,但有一个例外:我们现在希望将其中的每一列存储为分块数组,而不是存储为数组。这就是**arrow** 中的 Table
类所做的。
为了说明这一点,假设我们有一组作为记录批次到达的第二组数据
new_rb <- record_batch(
strs = c("I", "love", "you"),
ints = c(5L, 0L, 0L),
dbls = c(7.1, -0.1, 2)
)
df <- concat_tables(arrow_table(rb), arrow_table(new_rb))
df
## Table
## 8 rows x 3 columns
## $strs <string>
## $ints <int32>
## $dbls <double>
这是此表的底层结构