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

导航至

在这篇博客文章中,我们使用 Rust 实现的 parquet-rs(链接:https://crates.io/crates/parquet)量化了存储成千上万列的 Apache Parquet 文件的元数据开销、空间以及解码时间。我们得出结论,虽然关于 Parquet 元数据的技术问题是有根据的,但实际开销比普遍认为的要小。事实上,优化写入设置和简单的实现调整可以将开销降低 30-40%。通过显著的额外实现优化,解码速度可以提高高达 4 倍。

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

介绍

最近的一些断言表明,Parquet 不适合具有成千上万列的宽表,这在机器学习工作负载中很常见。常常伴随着这些断言提出新的文件格式,如 BtrBlocks、Lance V2 和 Nimble。

通常,所提出的理由是宽表有“大”的元数据,解码需要“很长时间”,通常比读取数据本身的时间要长。使用 Apache Thrift 存储元数据意味着对于每个文件,必须解码整个元数据负载,即使只需要一小部分列也是如此。在评估性能时,似乎还普遍存在(尽管是不正确的)将 Parquet(格式)与特定的 Parquet 实现(例如,parquet-java)等同起来的情况。

抛开许多查询系统缓存Parquet元数据以便更快处理的事实,我们想了解,所谓的元数据开销中有多少是由于Parquet格式限制造成的,有多少是由于实现不够优化或Parquet编写器配置不当造成的。

背景

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

layout-of-parquet-files

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

如图2所示,Parquet元数据结构与Parquet文件结构相似:它为每个行组包含条目,每个条目包含该行组中每个列块的详细信息。这意味着元数据大小为O(row_group * column),并随着行组数和列数的增加而线性增长。

除了解码每个列数据所需的信息,如起始偏移量和编码类型之外,元数据还可以选择性地存储每个列块的min、max和null计数。查询引擎,如Apache DataFusionDuckDB,可以使用这些统计数据来跳过解码行组和数据页面。

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

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

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

从存储加载元数据所需的时间取决于存储设备,范围从100us(本地SSD)到200ms(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 处理器上运行了基准测试。

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

结果

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

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

图 4:不同统计数据级别下 parquet-rs 的元数据解码时间和大小。左边的元数据解码时间图表还说明了 Thrift 解码和创建 Arrow Schema 之间的时间分解(请参阅图 6 以获取更详细的时间分解)。

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

在图 4 和 5 中,我们考察了统计数据对元数据解码速度和大小的影響。具体来说,我们将 parquet-rs 编写器配置为以下三种模式之一

  1. :无统计数据 (EnabledStatistics::None)
  2. :编写器为每个列块、每个行组存储最小值、最大值和空值统计信息 (EnabledStatistics::Chunk)
  3. 页面:(parquet-rs 的默认设置)。除了块级统计信息中写入的统计数据之外,编写器还写入来自 Parquet 页面索引 的结构,这可以 加快查询处理 (EnabledStatistics::Page)

图 5 绘制了这些设置的每列平均解码时间和元数据大小影响。请注意,我们预计禁用字符串列的统计数据的影响将比基于浮点数的测量更加显著,因为字符串统计数据值通常更大。

我们的发现如下

  1. 无统计数据时,元数据解码速度提高 30%,大小缩小 30%。
  2. 页面级统计数据只在块级统计数据的基础上添加了轻微的开销6
  3. 构建 Arrow 架构所需的时间可以忽略不计。
  4. 解码 Thrift 所需的时间是 Thrift 结构到 parquet-rs 结构转换时间的两倍。
  5. 使用最小的元数据(统计级别 ),每个额外的列将增加 5us 的解码时间和 700 字节存储需求。
  6. 我们的测量结果一致,误差线很小。
  7. parquet-rs 51.0.0 可以以 100MB/s(每兆字节元数据解码 10ms)的速率解码 Parquet 元数据。

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

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

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

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

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

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

结论

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

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

最后,在更广泛的整体系统中,例如从S3等对象存储读取数据时,我们相信元数据获取和解析不太可能成为瓶颈。鉴于对象存储中预期的首次字节访问延迟为100ms-200ms,通过适当地交织获取和解码,元数据解析可能只是整体执行时间的一小部分。

未来工作

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

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

致谢

我们感谢Raphael Taylor-Davies、Jörn Horstmann和Paul Dix对我们这篇帖子的早期版本的宝贵意见。


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

  2. 请参阅Exploiting Cloud Object Storage for High-Performance Analytics中的图表

  3. 注意事项:我们是 parquet-rs 的贡献者和维护者,因此可能存在偏见。

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

  5. 通过设置 WriterProperties::statistics_enabled

  6. 请注意,parquet-rs 读取器默认不使用 PageIndex 结构创建 Rust 结构,因此如果我们也进行解码,解码的开销可能会更高。

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