跳至内容

本文介绍了 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 未指定

根据 32 位有符号整数的要求,每个整数占用 4 个字节。请注意,与缺失值关联的字节未指定:为该值分配了空间,但未填充这些字节。

偏移缓冲区

某些类型的 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 位整数。

分块数组

数组是不可变的对象:一旦初始化了 Array,它存储的值就不能更改。这确保了多个实体可以安全地通过指针引用 Array,而不会冒值会更改的风险。使用不可变的 Array 使 Arrow 可以避免不必要的数据对象副本。

不可变的 Array 存在局限性,尤其是在新批次数据到达时。由于数组是不可变的,因此您无法将新信息添加到现有数组。如果您不想干扰或复制现有数组,则唯一可以做的就是创建一个包含新数据的新数组。这样做可以保留数组的不可变性,并且不会导致任何不必要的复制,但是现在我们遇到了一个新问题:数据被拆分到两个数组中。每个数组仅包含一个数据的“块”。理想情况下,应该有一个抽象层,使我们可以将这两个 Array 视为单个“类数组”对象。

这就是分块数组解决的问题。分块数组是数组列表的包装器,它允许您索引其内容,就好像它们是单个数组一样。从物理上讲,数据仍存储在不同的位置——每个数组都是一个块,并且这些块不必在内存中彼此相邻——但是分块数组为我们提供了一个抽象层,使我们可以假装它们都是一件事。

为了说明,让我们使用 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 对象的行为类似于具有行和列的二维结构,但是就它在内存中的表示方式而言,它从根本上来说是一个数组列表,如下所示

为了处理矩形数据集可以随着时间的推移而增长的情况(随着添加更多数据),我们需要一个类似于记录批次的表格数据结构,但有一个例外:我们现在不希望将每个列存储为数组,而是希望将其存储为分块数组。这就是 arrowTable 类的作用。

为了说明,假设我们有第二组作为记录批次到达的数据

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>

这是此表的基础结构