Apache Arrow 0.15 中更快的 C++ Apache Parquet 性能,用于字典编码的字符串数据


已发布 2019 年 9 月 5 日
作者 Wes McKinney (wesm)

我们一直在 Apache Parquet C++ 内部实现一系列优化,以提高 Arrow 柱状二进制和字符串数据的读取和写入效率(包括性能和内存使用),并为 Arrow 的字典类型提供新的“原生”支持。这应该会对 Parquet 文件的 C++、MATLAB、Python、R 和 Ruby 接口的用户产生重大影响。

本文回顾了已完成的工作,并展示了将 Arrow 0.12.1 与当前开发版本(即将作为 Arrow 0.15.0 发布)进行比较的基准测试。

工作总结

最大且最复杂的优化之一涉及将 Parquet 文件的内部字典编码数据流编码和解码为 Arrow 的内存字典编码 DictionaryArray 表示形式,以及从 Arrow 的内存字典编码 DictionaryArray 表示形式进行编码和解码。字典编码是 Parquet 中的一种压缩策略,并且没有正式的“字典”或“分类”类型。我将在下面详细介绍这一点。

与这项工作相关的一些特定 JIRA 问题包括

  • 向量化比较器以计算统计信息 (PARQUET-1523)
  • 将二进制数据直接读取到字典构建器中 (ARROW-3769)
  • 将 Parquet 的字典索引直接写入字典构建器 (ARROW-3772)
  • 将密集(非字典)Arrow 数组直接写入 Parquet 数据编码器 (ARROW-6152)
  • 直接将 arrow::DictionaryArray 写入 Parquet 列写入器 (ARROW-3246)
  • 支持更改字典 (ARROW-3144)
  • 内部 IO 优化和改进的原始 BYTE_ARRAY 编码性能 (ARROW-4398)

开发 Parquet C++ 库的挑战之一是我们维护不涉及 Arrow 柱状数据结构的低级读取和写入 API。因此,我们必须注意实施与 Arrow 相关的优化,而不会影响非 Arrow Parquet 用户,其中包括 Clickhouse 和 Vertica 等数据库系统。

背景:Parquet 文件如何进行字典编码

许多 Apache Arrow 的直接和间接用户使用字典编码来提高二进制或字符串数据类型的性能和内存使用率,这些数据类型包括许多重复的值。MATLAB 或 pandas 用户会将此称为分类类型(请参阅 MATLAB 文档pandas 文档),而在 R 中,这种编码被称为 factor。在 Arrow C++ 库和各种绑定中,我们有 DictionaryArray 对象,用于在内存中表示此类数据。

例如,像这样的数组

['apple', 'orange', 'apple', NULL, 'orange', 'orange']

具有字典编码形式

dictionary: ['apple', 'orange']
indices: [0, 1, 0, NULL, 1, 1]

Parquet 格式使用字典编码来压缩数据,它用于所有 Parquet 数据类型,而不仅仅是二进制或字符串数据。Parquet 进一步使用位填充和游程编码 (RLE) 来压缩字典索引,因此如果您有像这样的数据

['apple', 'apple', 'apple', 'apple', 'apple', 'apple', 'orange']

索引将被编码为

[rle-run=(6, 0),
 bit-packed-run=[1]]

rle-bitpacking 编码的完整详细信息可在 Parquet 规范中找到。

在写入 Parquet 文件时,大多数实现将使用字典编码来压缩列,直到字典本身达到某个大小阈值(通常约为 1 兆字节)。此时,列写入器将“回退”到 PLAIN 编码,其中值以端到端方式写入“数据页”,然后通常使用 Snappy 或 Gzip 进行压缩。请参见以下粗略的图表

Internal ColumnChunk structure

更快地读取和写入字典编码的数据

在读取 Parquet 文件时,字典编码的部分通常被物化为其非字典编码形式,从而导致二进制或字符串值在内存中重复。因此,一个明显的(但并非微不足道的)优化是跳过此“密集”物化。有几个问题需要处理

  • Parquet 文件通常为每个语义列包含多个 ColumnChunk,并且每个 ColumnChunk 中的字典值可能不同
  • 我们必须优雅地处理非字典编码的“回退”部分

我们采取了多种方法来帮助解决此问题

  • 允许每个 DictionaryArray 都有一个不同的字典(以前,字典是 DictionaryType 的一部分,这会导致问题)
  • 我们启用了将 Parquet 字典索引直接写入 Arrow DictionaryBuilder,而无需重新哈希数据
  • 在解码 ColumnChunk 时,我们首先将字典值和索引附加到 Arrow DictionaryBuilder,当遇到“回退”部分时,我们使用哈希表将这些值转换为字典编码形式
  • 在从 DictionaryArray 写入 ColumnChunk 时,我们会覆盖“回退”逻辑,以便更有效地读取此类数据

所有这些共同产生了一些出色的性能结果,我们将在下面详细介绍。

我们实施的另一类优化是删除低级 Parquet 列数据编码器和解码器类与 Arrow 柱状数据结构之间的抽象层。这包括

  • 添加 ColumnWriter::WriteArrowEncoder::Put 方法,这些方法直接接受 arrow::Array 对象
  • 添加 ByteArrayDecoder::DecodeArrow 方法以将二进制数据直接解码为 arrow::BinaryBuilder

虽然此工作带来的性能提升不如字典编码数据那么显着,但它们在实际应用中仍然有意义。

性能基准

我们运行了一些基准测试,将 Arrow 0.12.1 与当前的 master 分支进行比较。我们构建了两种 Arrow 表,每种表有 10 列

  • “低基数”和“高基数”变体。“低基数”案例具有 1,000 个唯一的字符串值,每个字符串值 32 字节。“高基数”具有 100,000 个唯一值
  • “密集”(非字典)和“字典”变体

请参阅完整的基准测试脚本。

我们展示了单线程和多线程读取性能。测试机是一台 Intel i9-9960X,使用 gcc 8.3.0(在 Ubuntu 18.04 上)具有 16 个物理内核和 32 个虚拟内核。所有时间测量均以秒为单位报告,但我们最感兴趣的是显示相对性能。

首先,是写入基准测试

Parquet write benchmarks

由于上述优化,写入 DictionaryArray 的速度要快得多。我们在写入密集(非字典)二进制数组方面取得了一些小幅改进。

然后,是读取基准测试

Parquet read benchmarks

同样,这里直接读取 DictionaryArray 的速度快很多倍。

这些基准测试表明,密集二进制数据的并行读取可能稍慢,但单线程读取现在更快。我们可能需要进行一些分析,看看我们能做些什么来使读取性能恢复正常。在这项工作中,相对于字典读取路径而言,优化密集读取路径并不是最重要的。

内存使用改进

除了更快的性能外,将列作为字典编码读取还可以显着减少内存使用。

在上面的 dict-random 案例中,我们发现主分支在加载 152 MB 的数据集时,RAM 的峰值使用量为 405 MB。 在 v0.12.1 中,加载相同的 Parquet 文件,但不使用加速字典支持,峰值内存使用量为 1.94 GB,而生成的非字典表占用 1.01 GB。

请注意,我们在 0.14.0 和 0.14.1 版本中存在一个内存过度使用 bug,已在 ARROW-6060 中修复。因此,如果您遇到了这个 bug,建议您尽快升级到 0.15.0 版本。

结论

未来我们还可以进行许多与 Parquet 相关的优化,但这里提到的优化在性能和内存使用方面对处理字符串密集型数据集的人员非常有帮助。 如果您想讨论这项开发工作,我们很乐意在我们的开发者邮件列表 dev@arrow.apache.org 上收到您的来信。