ValueVector#

ValueVector 接口 (在 C++ 实现中称为 Array,在规范中也是如此) 是一种抽象,用于存储单个列中具有相同类型的值序列。 在内部,这些值由一个或多个缓冲区表示,缓冲区的数量和含义取决于向量的数据类型。

对于规范中描述的每种基本数据类型和嵌套类型,都有 ValueVector 的具体子类。 在命名方面与规范中描述的类型名称存在一些差异:具有非直观名称的表格 (BigInt = 64 位整数等)。

重要的是,向量在尝试读取或写入之前进行分配,ValueVector “应该”努力保证这种操作顺序:创建 > 分配 > 变更 > 设置值计数 > 访问 > 清除(或分配以重新开始该过程)。 我们将在下一节中通过一个具体的示例来演示每个操作。

向量生命周期#

如上所述,每个向量在其生命周期中都经过多个步骤,并且每个步骤都由向量操作触发。 特别是,我们有以下向量操作

1. 向量创建:我们通过向量构造函数等方式创建一个新的向量对象。 以下代码通过构造函数创建一个新的 IntVector

RootAllocator allocator = new RootAllocator(Long.MAX_VALUE);
...
IntVector vector = new IntVector("int vector", allocator);

到目前为止,已创建了一个向量对象。 但是,尚未分配任何底层内存,因此我们需要以下步骤。

2. 向量分配:在此步骤中,我们为向量分配内存。 对于大多数向量,我们有两种选择:1) 如果我们知道最大向量容量,则可以通过调用 allocateNew(int) 方法来指定它; 2) 否则,我们应该调用 allocateNew() 方法,并为其分配默认容量。 对于我们的运行示例,我们假设向量容量永远不会超过 10

vector.allocateNew(10);

3. 向量变更:现在我们可以使用所需的值填充向量。 对于所有向量,我们可以通过向量编写器填充向量值(将在下一节中给出一个示例)。 对于原始类型,我们还可以通过 set 方法更改向量。 set 方法分为两类:1) 如果我们可以确定向量具有足够的容量,则可以调用 set(index, value) 方法。 2) 如果我们不确定向量容量,则应调用 setSafe(index, value) 方法,如果容量不足,该方法将自动处理向量重新分配。 对于我们的运行示例,我们知道向量具有足够的容量,因此我们可以调用

vector.set(/*index*/5, /*value*/25);

4. 设置值计数:对于此步骤,我们通过调用 setValueCount(int) 方法来设置向量的值计数

vector.setValueCount(10);

完成此步骤后,向量进入不可变状态。 换句话说,我们不应再更改它。 (除非我们通过再次分配来重用该向量。 这将在稍后讨论。)

5. 向量访问:现在可以访问向量值了。 同样,我们有两种选择来访问值:1) get 方法和 2) 向量读取器。 向量读取器适用于所有类型的向量,而 get 方法仅适用于原始向量。 向量读取器的具体示例将在下一节中给出。 下面是通过 get 方法访问向量的示例

int value = vector.get(5);  // value == 25

6. 向量清除:当我们完成向量的操作后,应清除它以释放其内存。 这可以通过调用 close() 方法来完成

vector.close();

关于上述步骤的一些注意事项

  • 这些步骤不一定按线性顺序执行。 相反,它们可以位于循环中。 例如,当向量进入访问步骤时,我们也可以返回到向量更改步骤,然后设置值计数、访问向量等等。

  • 我们应该尽量确保按顺序执行上述步骤。 否则,向量可能处于未定义状态,并且可能会发生一些意外行为。 但是,此限制并不严格。 这意味着我们可能会违反上述顺序,但仍然获得正确的结果。

  • 通过 set 方法更改向量值时,我们应尽可能首选 set(index, value) 方法而不是 setSafe(index, value) 方法,以避免处理向量容量时产生不必要的性能开销。

  • 所有向量都实现了 AutoCloseable 接口。 因此,当不再使用它们时,必须显式关闭它们,以避免资源泄漏。 为了确保这一点,建议将向量相关操作放入 try-with-resources 块中。

  • 对于固定宽度向量(例如 IntVector),我们可以按任意顺序在不同的索引处设置值。 但是,对于可变宽度向量(例如 VarCharVector),我们必须按索引的非递减顺序设置值。 否则,设置位置之后的值将变为无效。 例如,假设我们使用以下语句来填充可变宽度向量

VarCharVector vector = new VarCharVector("vector", allocator);
vector.allocateNew();
vector.setSafe(0, "zero");
vector.setSafe(1, "one");
...
vector.setSafe(9, "nine");

然后我们再次设置位置 5 的值

vector.setSafe(5, "5");

之后,向量位置 6、7、8 和 9 处的值将变为无效。

构建 ValueVector#

请注意,当前的实现不会强制执行 Arrow 对象是不可变的规则。 可以直接使用 new 关键字创建 ValueVector 实例,存在用于填充值的 set/setSafe API 和 FieldWriter 的具体子类。

例如,下面的代码演示了如何构建一个 BigIntVector,在本例中,我们构建一个范围为 0 到 7 的向量,其中应保存第四个值的元素为空

try (BufferAllocator allocator = new RootAllocator(Long.MAX_VALUE);
  BigIntVector vector = new BigIntVector("vector", allocator)) {
  vector.allocateNew(8);
  vector.set(0, 1);
  vector.set(1, 2);
  vector.set(2, 3);
  vector.setNull(3);
  vector.set(4, 5);
  vector.set(5, 6);
  vector.set(6, 7);
  vector.set(7, 8);
  vector.setValueCount(8); // this will finalizes the vector by convention.
  ...
}

BigIntVector 保存两个 ArrowBufs。 第一个缓冲区保存 null 位图,此处由单个字节组成,其位为 1|1|1|1|0|1|1|1(如果该值不为 null,则该位为 1)。 第二个缓冲区包含所有上述值。 由于第四个条目为 null,因此缓冲区中该位置的值未定义。 请注意,与 set API 相比,setSafe API 会在设置值之前检查值容量,并在必要时重新分配缓冲区。

以下是如何使用编写器构建向量

try (BigIntVector vector = new BigIntVector("vector", allocator);
  BigIntWriter writer = new BigIntWriterImpl(vector)) {
  writer.setPosition(0);
  writer.writeBigInt(1);
  writer.setPosition(1);
  writer.writeBigInt(2);
  writer.setPosition(2);
  writer.writeBigInt(3);
  // writer.setPosition(3) is not called which means the fourth value is null.
  writer.setPosition(4);
  writer.writeBigInt(5);
  writer.setPosition(5);
  writer.writeBigInt(6);
  writer.setPosition(6);
  writer.writeBigInt(7);
  writer.setPosition(7);
  writer.writeBigInt(8);
}

存在 get API 和 FieldReader 的具体子类用于访问向量值,需要声明的是,编写器/读取器不如直接访问有效

// access via get API
for (int i = 0; i < vector.getValueCount(); i++) {
  if (!vector.isNull(i)) {
    System.out.println(vector.get(i));
  }
}

// access via reader
BigIntReader reader = vector.getReader();
for (int i = 0; i < vector.getValueCount(); i++) {
  reader.setPosition(i);
  if (reader.isSet()) {
    System.out.println(reader.readLong());
  }
}

构建 ListVector#

ListVector 是一个向量,它保存每个索引的值列表。 使用一个向量需要处理与上述相同的步骤(创建 > 分配 > 变更 > 设置值计数 > 访问 > 清除),但是你完成此操作的详细信息略有不同,因为你需要同时创建向量并为每个索引设置值列表。

例如,以下代码演示了如何使用编写器 UnionListWriter 构建一个 int 的 ListVector。 我们构建一个从 0 到 9 的向量,每个索引都包含一个值列表 [[0, 0, 0, 0, 0], [0, 1, 2, 3, 4], [0, 2, 4, 6, 8], …, [0, 9, 18, 27, 36]]。 可以按任何顺序添加列表值,因此编写一个列表(例如 [3, 1, 2])也是有效的。

try (BufferAllocator allocator = new RootAllocator(Long.MAX_VALUE);
  ListVector listVector = ListVector.empty("vector", allocator)) {
  UnionListWriter writer = listVector.getWriter();
  for (int i = 0; i < 10; i++) {
     writer.startList();
     writer.setPosition(i);
     for (int j = 0; j < 5; j++) {
         writer.writeInt(j * i);
     }
     writer.setValueCount(5);
     writer.endList();
  }
  listVector.setValueCount(10);
}

可以通过 get API 或通过读取器类 UnionListReader 访问 ListVector 值。 要读取所有值,首先枚举索引,然后枚举内部列表值。

// access via get API
for (int i = 0; i < listVector.getValueCount(); i++) {
   if (!listVector.isNull(i)) {
       ArrayList<Integer> elements = (ArrayList<Integer>) listVector.getObject(i);
       for (Integer element : elements) {
           System.out.println(element);
       }
   }
}

// access via reader
UnionListReader reader = listVector.getReader();
for (int i = 0; i < listVector.getValueCount(); i++) {
   reader.setPosition(i);
   while (reader.next()) {
       IntReader intReader = reader.reader();
       if (intReader.isSet()) {
           System.out.println(intReader.readInteger());
       }
   }
}

字典编码#

字典编码是一种压缩形式,其中一种类型的值被较小类型的值替换:整数数组替换字符串数组就是一个常见的例子。 原始值和替换之间的映射保存在“字典”中。 由于字典只需要每个较长值的一个副本,因此字典和较小值数组的组合可能会使用更少的内存。 原始数据越重复,节省的越多。

FieldVector 可以进行字典编码,以提高性能或提高内存效率。 如果有很多值,但唯一值很少,则几乎可以对任何类型的向量进行编码。

编码过程涉及几个步骤

  1. 创建一个常规的、未编码的向量并填充它

  2. 创建一个与未编码向量类型相同的字典向量。 该向量必须具有相同的值,但未编码向量中的每个唯一值只需要在此处出现一次。

  3. 创建一个 Dictionary。 它将包含字典向量,以及一个 DictionaryEncoding 对象,该对象保存编码的元数据和设置值。

  4. 创建一个 DictionaryEncoder

  5. DictionaryEncoder 上调用 encode() 方法,以生成原始向量的编码版本。

  6. (可选)在编码向量上调用 decode() 方法以重新创建原始值。

编码后的值将是整数。根据您拥有的唯一值的数量,您可以使用 TinyIntVectorSmallIntVectorIntVectorBigIntVector 来保存它们。您在创建 DictionaryEncoding 实例时指定类型。您可能想知道这些整数来自哪里:字典向量是一个常规向量,因此该值在向量中的索引位置用作其编码值。

DictionaryEncoding 中的另一个关键属性是 id。了解 id 的使用方式非常重要,因此我们将在本节稍后介绍。

此结果将是一个新向量(例如,一个 IntVector),它可以代替原始向量(例如,一个 VarCharVector)。当您以 Arrow 格式写入数据时,写入的是新的 IntVector 和字典:您稍后需要该字典才能检索原始值。

// 1. create a vector for the un-encoded data and populate it
VarCharVector unencoded = new VarCharVector("unencoded", allocator);
// now put some data in it before continuing

// 2. create a vector to hold the dictionary and populate it
VarCharVector dictionaryVector = new VarCharVector("dictionary", allocator);

// 3. create a dictionary object
Dictionary dictionary = new Dictionary(dictionaryVector, new DictionaryEncoding(1L, false, null));

// 4. create a dictionary encoder
DictionaryEncoder encoder = new DictionaryEncoder.encode(dictionary, allocator);

// 5. encode the data
IntVector encoded = (IntVector) encoder.encode(unencoded);

// 6. re-create an un-encoded version from the encoded vector
VarCharVector decoded = (VarCharVector) encoder.decode(encoded);

我们尚未讨论的一件事是如何从原始的未编码值创建字典向量。这留给库用户来处理,因为自定义方法可能比通用实用程序更有效。由于字典向量只是一个普通向量,因此您可以使用标准 API 填充其值。

最后,您可以将多个字典打包在一起,这在您使用带有多个字典编码向量的 VectorSchemaRoot 时非常有用。这是使用名为 DictionaryProvider 的对象完成的,如下例所示。请注意,我们不会将字典向量放在与数据向量相同的 VectorSchemaRoot 中,因为它们通常具有更少的值。

DictionaryProvider.MapDictionaryProvider provider =
    new DictionaryProvider.MapDictionaryProvider();

provider.put(dictionary);

DictionaryProvider 只是标识符到 Dictionary 对象的映射,其中每个标识符都是一个长整数值。在上面的代码中,您会看到它作为 DictionaryEncoding 构造函数的第一个参数。

这就是 DictionaryEncoding 的 ‘id’ 属性的用处。该值用于使用 DictionaryProvider 将字典连接到 VectorSchemaRoot 的实例。 这是它的工作原理:

  • VectorSchemaRoot 具有一个包含 Field 对象列表的 Schema 对象。

  • 该字段具有一个名为 ‘dictionary’ 的属性,但它保存的是 DictionaryEncoding 而不是 Dictionary

  • 如前所述,DictionaryProvider 保存由长整数值索引的字典。 此值是来自您的 DictionaryEncoding 的 id。

  • 要检索 VectorSchemaRoot 中向量的字典,您需要获取与该向量关联的字段,获取其 dictionary 属性,并使用该对象的 id 在 provider 中查找正确的字典。

// create the encoded vector, the Dictionary and DictionaryProvider as discussed above

// Create a VectorSchemaRoot with one encoded vector
VectorSchemaRoot vsr = new VectorSchemaRoot(List.of(encoded));

// now we want to decode our vector, so we retrieve its dictionary from the provider
Field f = vsr.getField(encoded.getName());
DictionaryEncoding encoding = f.getDictionary();
Dictionary dictionary = provider.lookup(encoding.getId());

正如您所看到的,DictionaryProvider 对于管理与 VectorSchemaRoot 关联的字典非常有用。 更重要的是,它有助于在写入 VectorSchemaRoot 时打包字典。 类 ArrowFileWriterArrowStreamWriter 都接受可选的 DictionaryProvider 参数以实现此目的。 您可以在 (Reading/Writing IPC formats) 的文档中找到有关写入字典的示例代码。ArrowReader 及其子类也实现了 DictionaryProvider 接口,因此您可以在读取文件时检索实际的字典。

切片#

与 C++ 实现类似,可以制作向量的零拷贝切片,以通过 TransferPair 获得指向数据某些逻辑子序列的向量

IntVector vector = new IntVector("intVector", allocator);
for (int i = 0; i < 10; i++) {
  vector.setSafe(i, i);
}
vector.setValueCount(10);

TransferPair tp = vector.getTransferPair(allocator);
tp.splitAndTransfer(0, 5);
IntVector sliced = (IntVector) tp.getTo();
// In this case, the vector values are [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] and the sliceVector values are [0, 1, 2, 3, 4].