Parquet 对于宽表(机器学习工作负载)真的有那么好吗?

导航至

在这篇博文中,我们量化了使用 Apache Parquet 文件存储数千列的元数据开销,以及使用 Rust 实现的 parquet-rs 的空间和解码时间。我们的结论是,虽然对 Parquet 元数据的技术担忧是合理的,但实际的开销比普遍认为的要小。事实上,优化写入器设置和简单的实现调整可以将开销减少 30-40%。通过进一步的实现优化,解码速度可以提高高达 4 倍。

图 1:使用 parquet-rs 解码 1000 列 Parquet Float64 列的元数据解码时间。配置写入器以省略统计信息可将解码性能提高 30%(9.1 毫秒 → 6.9 毫秒)。标准软件工程优化技术将解码性能再提高 40%(6.9 毫秒 → 4.1 毫秒 / 9.1 毫秒 → 6.4 毫秒)

简介

最近的断言表明,Parquet 不适用于具有数千列的宽表,这在 机器学习工作负载中经常发现。对新文件格式的提议,例如 BtrBlocksLance V2Nimble1,通常伴随着这些断言。

通常,声明的理由是宽表具有“大型”元数据,这需要“很长时间”才能解码,通常比读取数据本身更长。使用 Apache Thrift 存储元数据意味着即使只需要一小部分列,也必须为每个文件解码整个元数据有效负载。在评估性能时,将 Parquet(格式)等同于特定的 Parquet 实现(例如,parquet-java)似乎也很常见(尽管不正确)。

撇开许多查询系统缓存来自 Parquet 元数据的信息以进行更快处理这一事实,我们希望获得关于所谓的元数据开销在多大程度上是由于 Parquet 格式的限制,以及在多大程度上是由于 Parquet 写入器的优化程度较低的实现或配置不当的设置造成的定量信息。

背景

Parquet 文件包含解释文件所需的元数据。此元数据还指示读取器仅加载回答查询所需的文件部分。使用毫秒级延迟查询 Parquet中可以找到有关这些技术的更多信息。典型的 Parquet 文件大小为 GB 级,但许多查询仅读取一小部分,因此元数据通常对于快速查找所需数据至关重要。

layout-of-parquet-files

图 2:Parquet 文件布局。元数据存储在页脚(文件末尾),并包含文件中页面的位置以及可选的统计信息,例如每个列块的最小/最大/空值计数。

如图 2 所示,Parquet 元数据的结构反映了 Parquet 文件的结构:它包含每个行组的条目,并且每个条目包含该行组内每个列块的信息。这意味着元数据大小为 O(行组 * 列),并且随着行组数量和列数的增加呈线性增长。

除了解码每列数据所需的信息(例如起始偏移量和编码类型)外,元数据还可以选择存储每个列块的最小值、最大值和空值计数。查询引擎(例如 Apache DataFusionDuckDB)可以使用这些统计信息完全跳过解码行组和数据页。

元数据以 Apache Thrift 格式编码,该格式类似于 protobuf。Thrift 使用可变长度编码来实现高空间效率。尽管如此,可变长度编码要求 Parquet 读取器在读取任何内容之前获取并可能检查整个元数据页脚。例如,如果不从头开始,则无法直接跳转到元数据中读取单行组所需的位置。

在 parquet-rs 的 ArrowReader 中读取 Parquet 元数据需要三个步骤

  1. 将元数据从存储加载到内存
  2. 将 Thrift 格式的数据解码为内存结构:ParquetMetadata
  3. 从 Parquet SchemaDescriptor 构建 Arrow Schema

从存储加载元数据所需的时间取决于存储设备,范围从 100 微秒(本地 SSD)到 200 毫秒(S3)2。如图 4 和图 6 所示,一旦数据进入内存,从 Thrift 解码为 Rust 结构是迄今为止最耗时的活动。这是有道理的,因为解码会将微小的紧凑编码 (Thrift) 膨胀为点可访问的内存 Rust 结构。将 SchemaDescriptor 转换为 Arrow Schema 也需要少量 CPU 时间。

测试平台

实现:我们使用 parquet–rs3,Parquet 的 Rust 实现,版本 51.0.0 进行了实验。我们为每个文件重复每个实验五次,并报告最后四次执行的平均时间,以排除缓存的影响。您可以在此处找到基准测试代码。我们在主频为 5.4 GHz、具有 32 MB L3 缓存的 AMD 7600X 处理器上运行了基准测试。

工作负载:我们生成了几个 Parquet 文件,其中包含 10 到 100,0004 列 Float64 列,模仿机器学习工作负载。由于我们专注于元数据,因此我们只是多次写入相同重复的值作为数据。每个 Parquet 文件包含十个行组,并且由于每个行组都包含所有列,因此 Parquet 元数据编码 10 * column_count 个单独的 ColumnChunk 结构。为了研究包含统计信息的影响,我们测试了三种配置:无统计信息、块级统计信息和页级统计信息(parquet-rs 中的默认设置)。

结果

图 3:parquet-rs 的元数据解码时间和大小与 Parquet 文件中 Float64 列数的对比。请注意,x 轴和 y 轴均为对数刻度。

图 3 绘制了随着列数从 10 增加到 100,000,元数据大小和解码时间之间的关系。正如预期的那样,元数据大小和解码时间与 Parquet 文件中的列数成线性比例关系。

图 4: parquet-rs 在不同统计信息级别下的元数据解码时间和大小。元数据解码时间图(左)还说明了 Thrift 解码和创建 Arrow Schema 之间的时间细分(有关更详细的细分,请参见图 6)。

图 5:平均每列解码时间和元数据大小。x 轴显示统计信息级别;y 轴显示每列的时间和大小。

在图 4 和图 5 中,我们检查了统计信息对元数据解码速度和大小的影响。具体来说,我们以以下三种模式之一配置5了 parquet-rs 写入器

  1. none:无统计信息 (EnabledStatistics::None)
  2. chunk:写入器存储每个行组的每个列块的最小值、最大值和空值计数统计信息 (EnabledStatistics::Chunk)
  3. page:(parquet-rs 的默认设置)。除了在块级别写入的统计信息外,写入器还写入来自 Parquet Page Index 的结构,这可以加速查询处理 (EnabledStatistics::Page)

图 5 以图表形式显示了这些设置对平均每列解码时间和元数据大小的影响。请注意,我们预计禁用字符串列的统计信息的影响将比我们基于浮点数的测量结果更为显着,因为字符串统计信息值通常更大。

我们的发现如下

  1. 在没有统计信息的情况下,元数据解码速度提高 30%,大小比默认级别小 30%。
  2. 页级统计信息仅在块级统计信息6之上增加少量开销。
  3. 构建 Arrow schema 所需的时间可以忽略不计。
  4. 解码 Thrift 所需的时间是将 Thrift 结构转换为 parquet-rs 结构的两倍。
  5. 在最小元数据(统计信息级别为none)的情况下,每个附加列会使解码时间增加 5 微秒,存储需求增加 700 字节。
  6. 我们的测量结果是一致的,并且误差条很小。
  7. parquet-rs 51.0.0 可以以 100MB/s 的速度解码 Parquet 元数据(解码每兆字节元数据需要 10 毫秒)。

我们的发现表明,专注于提高 Thrift 解码和 Thrift 到 parquet-rs 结构转换效率的软件优化工作将直接转化为提高整体元数据解码速度。

图 6:元数据解码时间细分详细分析。

最后,我们使用分析器分析了解码,并将结果绘制在图 6 中。

  • 61% 的时间用于解码和构建 FileMetaData,其中包括 Parquet schema。
  • 31% 的时间用于构建 RowGroupMetaData,它将解码后的 Thrift 数据结构转换为 parquet-rs 数据结构。
  • 7% 的时间用于构建 Arrow schema。

图 7: 简单的软件工程优化(例如,更好的分配器、优化的内存布局和 SIMD 加速)将解码吞吐量提高了高达 75%。

最后,我们花费了几天时间原型化简单的工程优化(例如,更好的分配器、优化的内存布局和 SIMD 加速),以提高解码性能。图 7 显示,即使进行微小的代码更改(少于 100 行代码,API 没有变化),我们也可以将解码性能提高高达 75%。其他社区成员也讨论并原型化了一些更深入的更改,例如减少分配(约 2 倍的改进)和更优化的 Thrift 解码器(又提高了约 2 倍)。

结论

对于元数据大小和解码速度是最重要因素的工作负载,配置 Parquet 写入器不写入统计信息7可在不进行其他软件更改的情况下将速度和空间提高 30%。

虽然 Rust Parquet 实现对于元数据解码已经相当快,但显著提高速度的潜力触手可及。通过应用简单的软件工程技术,解码速度可以提高约 4 倍。对现有解码器的这项投资可能比创建全新的格式产生更大的回报。

最后,在更广泛的整体系统中,从 S3 等对象存储读取数据是很常见的,我们认为元数据获取和解析不太可能成为重要的瓶颈。考虑到对象存储中预期的首字节访问延迟为 100 毫秒至 200 毫秒,通过适当地交错获取和解码,元数据解析很可能只是整体执行时间的一小部分。

未来工作

我们没有探索的几个领域值得进一步关注

  • 其他开源 Parquet 实现(例如 parquet-javaparquet-cpp)的类似性能比较
  • 针对 String/Binary 列的 Parquet 元数据大小和解码速度的类似研究。我们预计禁用统计信息和优化解码器实现的好处对于此类列将显着提高,因为统计信息中存储的值要大得多。
  • Lance V2 Nimble 等新提出的格式进行类似的研究将有助于我们了解它们在处理大量列方面有多好,以及可能存在的其他权衡。特别是,这些新格式结合了轻量级元数据/统计信息(例如,更小的、解耦的元数据)和/或允许部分解码,即仅解码投影列而不是整个元数据,这应该可以大大加快解码时间。

致谢

我们要感谢 Raphael Taylor-Davies、Jörn Horstmann 和 Paul Dix 对本文早期版本的有益评论。


  1. 有关更多信息,请参阅 [email protected] 邮件列表中的讨论

  2. 请参阅 利用云对象存储实现高性能分析中的图表

  3. 注意:我们作为 parquet-rs 的贡献者和维护者,存在偏见

  4. 请注意,使用默认写入器设置,我们的测试平台在写入 100,000 列 Parquet 文件时内存不足。我们发现通过将 data_page_row_count 设置为 10,000 可以解决此问题。使用默认(无限制)数据页行计数,我们发现 Parquet 写入器消耗了超过 80GB 的内存。我们已经开始讨论更改此默认值,因为另一个常见的批评是在宽表中使用 Parquet 时,写入器需要大型内存缓冲区,但我们认为这可能是由于默认写入器设置造成的。

  5. 通过设置 WriterProperties::statistics_enabled

  6. 请注意,parquet-rs 读取器默认情况下不会从 PageIndex 结构创建 Rust 结构,通过 default,因此如果我们也解码此结构,解码开销可能会更高。

  7. 当然,这可能会影响从统计信息中受益的工作负载的查询性能(例如,它们在受影响的列上具有谓词)。