值向量#

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

规范中描述的每种基本数据类型和嵌套类型都有 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 处的值将变为无效。

构建值向量#

请注意,当前实现并未强制执行 Arrow 对象不可变的规则。ValueVector 实例可以通过使用 new 关键字直接创建,可以使用 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 包含两个 ArrowBuf。第一个缓冲区保存空位图,这里由一个字节组成,位为 1|1|1|1|0|1|1|1(如果值非空,则位为 1)。第二个缓冲区包含上述所有值。由于第四个条目为空,因此缓冲区中该位置的值未定义。请注意,与 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 参数用于此目的。您可以在(读取/写入 IPC 格式)的文档中找到写入字典的示例代码。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].