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 表示形式。字典编码是 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 位打包编码的完整细节可在 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::WriteArrowEncoder::Put 方法
  • 添加 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 案例中,我们发现 master 分支在加载 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 上的来信。