Apache Arrow 0.15 中即将推出的针对字典编码字符串数据的 C++ Apache Parquet 性能提升


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

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

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

工作总结

其中一项最大、最复杂的优化涉及将 Parquet 文件的内部字典编码数据流与 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 用户将其称为 Categorical 类型(请参阅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 列式数据结构之间的抽象层。这包括

  • 添加直接接受 `arrow::Array` 对象的 `ColumnWriter::WriteArrow` 和 `Encoder::Put` 方法
  • 添加 `ByteArrayDecoder::DecodeArrow` 方法以将二进制数据直接解码到 `arrow::BinaryBuilder` 中。

虽然这项工作的性能改进不如字典编码数据那么显著,但在实际应用中仍然很有意义。

性能基准测试

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

  • “低基数”和“高基数”变体。“低基数”情况有 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 数据集时峰值使用 405 MB 内存。在 v0.12.1 中,在没有加速字典支持的情况下加载相同的 Parquet 文件会使用 1.94 GB 的峰值内存,而生成的非字典表占用 1.01 GB。

请注意,我们在 0.14.0 和 0.14.1 版本中发现了一个内存过度使用错误,该错误已在 ARROW-6060 中修复,因此,如果您遇到此错误,则需要尽快升级到 0.15.0。

结论

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