GH-50689: [C++][Parquet] Add IEEE-754 total order and nan count for floating types - #50807
GH-50689: [C++][Parquet] Add IEEE-754 total order and nan count for floating types#50807HuaHuaY wants to merge 6 commits into
Conversation
9b4091a to
5812e60
Compare
| ARROW_SUPPRESS_DEPRECATION_WARNING= \ | ||
| ARROW_UNSUPPRESS_DEPRECATION_WARNING= \ | ||
| GANDIVA_EXPORT= \ | ||
| PARQUET_DEPRECATED(x)= \ |
There was a problem hiding this comment.
Add this because I added a PARQUET_DEPRECATED at cpp/src/parquet/statistics.h and ci failed. https://github.com/apache/arrow/actions/runs/30975844655/job/92209544482
I believe this is a long-standing issue. If someone would like me to submit a separate PR to fix it, I can certainly do so.
bd13067 to
f51ffc1
Compare
a306ade to
2f24bb6
Compare
There was a problem hiding this comment.
Pull request overview
This PR updates Arrow C++’s Parquet implementation to support IEEE-754 total ordering for floating-point statistics and to record NaN counts in statistics and page indexes, aligning with parquet-format changes (apache/parquet-format#514) and improving correctness for pruning, encoding, and metadata round-trips involving NaNs and signed zeros.
Changes:
- Add
ColumnOrder::IEEE_754_TOTAL_ORDERand a writer property to control floating-point column ordering (defaulting to IEEE total order). - Track and serialize
nan_countin column/page statistics and expose per-pagenan_countsviaColumnIndex. - Update statistics computation, dictionary encoding, metadata/schema handling, and dataset pruning to be NaN-aware (including special handling for FLOAT16 pruning limitations).
Reviewed changes
Copilot reviewed 28 out of 28 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| docs/source/python/parquet.rst | Update example metadata output values to match new behavior/encoding sizes. |
| cpp/src/parquet/types.h | Add IEEE_754_TOTAL_ORDER to ColumnOrder enum and declare static instance. |
| cpp/src/parquet/types.cc | Define ColumnOrder::ieee_754_total_order_. |
| cpp/src/parquet/thrift_internal.h | Serialize/deserialize nan_count; treat IEEE order as min/max-value stats field source. |
| cpp/src/parquet/statistics.h | Extend statistics APIs and encoded state to include optional nan_count; update comparator docs. |
| cpp/src/parquet/statistics.cc | Implement IEEE total-order comparisons, NaN counting, and IEEE-aware min/max handling and merging. |
| cpp/src/parquet/statistics_test.cc | Add/extend tests for NaN counts, IEEE total order, and float16 behaviors. |
| cpp/src/parquet/schema.h | Allow can_use_min_max() for IEEE order (see review note re: non-float types). |
| cpp/src/parquet/schema.cc | Add IsFloatingPointType helper for schema/metadata decisions. |
| cpp/src/parquet/schema_test.cc | Test that IEEE column order allows min/max usage. |
| cpp/src/parquet/schema_internal.h | Expose IsFloatingPointType as a non-public schema utility. |
| cpp/src/parquet/properties.h | Add writer property floating_point_column_order with validation and plumbing. |
| cpp/src/parquet/page_index.h | Add has_nan_counts() / nan_counts() to ColumnIndex API. |
| cpp/src/parquet/page_index.cc | Persist per-page nan_counts when available for floating columns; validate vector lengths. |
| cpp/src/parquet/page_index_test.cc | Add tests covering nan_counts propagation for IEEE total-order float columns. |
| cpp/src/parquet/metadata.cc | Plumb encoded stats into Statistics::Make; enforce column-order compatibility; parse/write IEEE column order in file metadata. |
| cpp/src/parquet/file_writer.cc | Build writer schema with per-float column orders from writer properties and stabilize type_length. |
| cpp/src/parquet/file_serialize_test.cc | Add round-trip test validating floating-point column order behavior and schema stability. |
| cpp/src/parquet/encoding_test.cc | Add tests ensuring dictionary encoding preserves float bit patterns and avoids NaN hash collisions. |
| cpp/src/parquet/encoder.cc | Change float/double dictionary memoization keys to use bitwise representations for NaN payload stability. |
| cpp/src/parquet/column_writer.cc | Gate legacy min/max field population based on effective ordering; enable stats collection via can_use_min_max(). |
| cpp/src/parquet/arrow/reader_internal.cc | Decode FLOAT16 min/max statistics into HalfFloat scalars for Arrow conversion. |
| cpp/src/parquet/arrow/index_test.cc | Add nan_counts to column index round-trip expectations; add test parquet file coverage for mixed orders/nan counts. |
| cpp/src/parquet/arrow/arrow_reader_writer_test.cc | Add end-to-end test ensuring float dictionary round-trips preserve NaN payloads and signed zeros with IEEE order and page index. |
| cpp/src/arrow/dataset/file_parquet.cc | Make dataset pruning NaN-aware using nan_count; skip FLOAT16 numeric pruning until kernels exist. |
| cpp/src/arrow/dataset/file_parquet_test.cc | Add tests validating pruning expressions with/without nan_count and for FLOAT16 behavior. |
| cpp/apidoc/Doxyfile | Teach Doxygen about PARQUET_DEPRECATED macro for API docs. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| case ColumnOrder::IEEE_754_TOTAL_ORDER: | ||
| return true; |
There was a problem hiding this comment.
Although this is a public function, we have already checked the type before reading and writing files, so I don't think we need to do any extra checks here.
|
Run |
| int16_t max_repetition_level = 0, | ||
| const std::vector<PageLevelHistogram>& page_levels = {}) { | ||
| const bool build_size_stats = !page_levels.empty(); | ||
| const bool has_nan_counts = std::all_of( |
There was a problem hiding this comment.
nit: I guess that you don't want to change the signature here but currently has_nan_counts and has_null_counts are handled differently. It would be more readable to use consistent approach to pass or compute both of them.
| /// \param[in] is_min_value_exact whether the min value is exact | ||
| /// \param[in] is_max_value_exact whether the max value is exact | ||
| /// \param[in] pool a memory pool to use for any memory allocations, optional | ||
| static std::shared_ptr<Statistics> Make( |
There was a problem hiding this comment.
Why not just directly extending the above Make function? We already have several overloads now.
| virtual int64_t distinct_count() const = 0; | ||
|
|
||
| /// \brief Return true if the count of NaN values is set | ||
| virtual bool HasNanCount() const = 0; |
There was a problem hiding this comment.
Why not combine HasNanCount and nan_count just like std::optional<bool> is_min_value_exact() does?
| } | ||
| } | ||
|
|
||
| TEST(TestDictionaryEncoding, FloatingPointBits) { |
There was a problem hiding this comment.
Do we need to test float16 here?
| dictionary->WriteDict(buffer->mutable_data()); | ||
| const UInt* encoded = reinterpret_cast<const UInt*>(buffer->data()); | ||
| for (int value_index = 0; value_index < num_entries; ++value_index) { | ||
| EXPECT_EQ(bits[value_index], encoded[value_index]); |
There was a problem hiding this comment.
This is a little bit fraigle since it enforces that all distinct values should appear in the beginning of bits. Should we remove num_entries and add a const std::array<UInt, NumValues>& expected_bits to the input parameter instead?
| std::transform(bits.begin(), bits.end(), values.begin(), | ||
| [](UInt value) { return ::arrow::util::SafeCopy<T>(value); }); | ||
|
|
||
| auto encoder = MakeTypedEncoder<DType>(Encoding::PLAIN, true); |
There was a problem hiding this comment.
| auto encoder = MakeTypedEncoder<DType>(Encoding::PLAIN, true); | |
| auto encoder = MakeTypedEncoder<DType>(Encoding::PLAIN, /*use_dictionary=*/true); |
Same apply to others
| assert_orders(&input_schema, ColumnOrder::TYPE_DEFINED_ORDER); | ||
| assert_orders(type_writer->schema(), ColumnOrder::TYPE_DEFINED_ORDER); | ||
| assert_orders(type_file->schema(), ColumnOrder::TYPE_DEFINED_ORDER); | ||
| EXPECT_THROW(ieee_file->AppendRowGroups(*type_file), ParquetException); |
| assert_orders(&input_schema, ColumnOrder::TYPE_DEFINED_ORDER); | ||
|
|
||
| auto [ieee_writer, ieee_file] = write_orders(ColumnOrder::IEEE_754_TOTAL_ORDER); | ||
| assert_orders(&input_schema, ColumnOrder::TYPE_DEFINED_ORDER); |
There was a problem hiding this comment.
Here we test that input schema has not been altered?
|
|
||
| namespace { | ||
|
|
||
| std::shared_ptr<GroupNode> MakeWriterSchema(const GroupNode& input_schema, |
There was a problem hiding this comment.
Should we remove this function? The Open function below should preserve the input schema. This provides the flexibility of low-level api to enable users to mix ieee754 and type_defined order for different floating-point columns.
Instead, we can move floating_point_column_order() to ArrowWriterProperties so we are converting Arrow schema to parquet GroupNode with expected column order at all once.
| auto msg = "AppendRowGroups requires equal schemas.\n" + diff_output.str(); | ||
| throw ParquetException(msg); | ||
| } | ||
| for (int column_index = 0; column_index < schema()->num_columns(); ++column_index) { |
There was a problem hiding this comment.
Shouldn't we check this in the above if (!schema()->Equals(*other->schema(), &diff_output))?
| auto file_meta_data = std::unique_ptr<FileMetaData>(new FileMetaData()); | ||
| file_meta_data->impl_->metadata_ = std::move(metadata_); | ||
| file_meta_data->impl_->InitSchema(); | ||
| file_meta_data->impl_->InitColumnOrders(); |
There was a problem hiding this comment.
Does it mean that parquet-cpp has never emitted column orders?
| /// elements with accompanying bitmap indicating which elements are | ||
| /// included (bit set) and excluded (bit not set) | ||
| /// | ||
| /// For floating-point types with ColumnOrder::TYPE_DEFINED_ORDER, NaNs are |
There was a problem hiding this comment.
std::pair<T, T> GetMinMax(const ::arrow::Array& values) does not have this comment. Is it because we have not implemented this new order to it? Should we be explicit about this?
| bool TypedStatisticsImpl<DType>::MinMaxEqual( | ||
| const TypedStatisticsImpl<DType>& other) const { | ||
| if constexpr (IsOneOf<T, float, double>::value) { | ||
| if (descr_->column_order().get_order() == ColumnOrder::IEEE_754_TOTAL_ORDER) { |
There was a problem hiding this comment.
It seems that above TypedStatisticsImpl<FLBAType>::MinMaxEqual uses bit-wise comparison for float16 regardless of column order. I think this is acceptable.
|
|
||
| int64_t null_count() const override { return statistics_.null_count; } | ||
| int64_t distinct_count() const override { return statistics_.distinct_count; } | ||
| int64_t nan_count() const override { return statistics_.nan_count.value_or(0); } |
There was a problem hiding this comment.
I still think we should just return std::optional<int64_t> for this. If you insist to return int64_t, return -1 if missing seems more appropriate.
| ::testing::HasSubstr("BloomFilterBuilder does not support boolean type")); | ||
| } | ||
|
|
||
| TEST(ParquetPageIndex, FloatingPointOrders) { |
There was a problem hiding this comment.
Rename it to indicate it is an interoperability test.
There was a problem hiding this comment.
BTW, I think this test case is a little bit weak. This file was added by apache/parquet-testing#104 and it contains following attributes:
» parquet-cli meta target/parquet-testing/data/floating_orders_nan_count.parquet
File path: data/floating_orders_nan_count.parquet
Created by: parquet-mr version 1.18.0-SNAPSHOT (build c5dcd8ca5bad5fde9c797b876a16b5bf3b9206c0)
Properties:
original.created.by: parquet-mr version 1.18.0-SNAPSHOT (build c5dcd8ca5bad5fde9c797b876a16b5bf3b9206c0)
writer.model.name: example
Schema:
message msg {
required float float_ieee754;
required float float_typedef;
required double double_ieee754;
required double double_typedef;
required fixed_len_byte_array(2) float16_ieee754 (FLOAT16);
required fixed_len_byte_array(2) float16_typedef (FLOAT16);
}
Row group 0: count: 10 42.20 B records start: 4 total(compressed): 422 B total(uncompressed):422 B
--------------------------------------------------------------------------------
type encodings count avg size nulls min / max
float_ieee754 FLOAT _ _ 10 6.30 B 0 "-2.0" / "5.0"
float_typedef FLOAT _ _ 10 6.30 B 0 "-2.0" / "5.0"
double_ieee754 DOUBLE _ _ 10 10.50 B 0 "-2.0" / "5.0"
double_typedef DOUBLE _ _ 10 10.50 B 0 "-2.0" / "5.0"
float16_ieee754 FIXED[2] _ _ 10 4.30 B 0 "-2.0" / "5.0"
float16_typedef FIXED[2] _ _ 10 4.30 B 0 "-2.0" / "5.0"
Row group 1: count: 10 42.20 B records start: 426 total(compressed): 422 B total(uncompressed):422 B
--------------------------------------------------------------------------------
type encodings count avg size nulls min / max
float_ieee754 FLOAT _ _ 10 6.30 B 0 "-2.0" / "3.0"
float_typedef FLOAT _ _ 10 6.30 B 0
double_ieee754 DOUBLE _ _ 10 10.50 B 0 "-2.0" / "3.0"
double_typedef DOUBLE _ _ 10 10.50 B 0
float16_ieee754 FIXED[2] _ _ 10 4.30 B 0 "-2.0" / "3.0"
float16_typedef FIXED[2] _ _ 10 4.30 B 0
Row group 2: count: 10 42.20 B records start: 848 total(compressed): 422 B total(uncompressed):422 B
--------------------------------------------------------------------------------
type encodings count avg size nulls min / max
float_ieee754 FLOAT _ _ 10 6.30 B 0 "NaN" / "NaN"
float_typedef FLOAT _ _ 10 6.30 B 0
double_ieee754 DOUBLE _ _ 10 10.50 B 0 "NaN" / "NaN"
double_typedef DOUBLE _ _ 10 10.50 B 0
float16_ieee754 FIXED[2] _ _ 10 4.30 B 0 "NaN" / "NaN"
float16_typedef FIXED[2] _ _ 10 4.30 B 0
Row group 3: count: 10 42.20 B records start: 1270 total(compressed): 422 B total(uncompressed):422 B
--------------------------------------------------------------------------------
type encodings count avg size nulls min / max
float_ieee754 FLOAT _ _ 10 6.30 B 0 "0.0" / "5.0"
float_typedef FLOAT _ _ 10 6.30 B 0 "-0.0" / "5.0"
double_ieee754 DOUBLE _ _ 10 10.50 B 0 "0.0" / "5.0"
double_typedef DOUBLE _ _ 10 10.50 B 0 "-0.0" / "5.0"
float16_ieee754 FIXED[2] _ _ 10 4.30 B 0 "0.0" / "5.0"
float16_typedef FIXED[2] _ _ 10 4.30 B 0 "-0.0" / "5.0"
Row group 4: count: 10 42.20 B records start: 1692 total(compressed): 422 B total(uncompressed):422 B
--------------------------------------------------------------------------------
type encodings count avg size nulls min / max
float_ieee754 FLOAT _ _ 10 6.30 B 0 "-5.0" / "-0.0"
float_typedef FLOAT _ _ 10 6.30 B 0 "-5.0" / "0.0"
double_ieee754 DOUBLE _ _ 10 10.50 B 0 "-5.0" / "-0.0"
double_typedef DOUBLE _ _ 10 10.50 B 0 "-5.0" / "0.0"
float16_ieee754 FIXED[2] _ _ 10 4.30 B 0 "-5.0" / "-0.0"
float16_typedef FIXED[2] _ _ 10 4.30 B 0 "-5.0" / "0.0"
It would be better to verify column chunk stats and page index of every row group and column are correctly parsed.
| case ColumnOrder::TYPE_DEFINED_ORDER: | ||
| return sort_order() != SortOrder::UNKNOWN; | ||
| case ColumnOrder::IEEE_754_TOTAL_ORDER: | ||
| return true; |
There was a problem hiding this comment.
Should we call IsFloatingPointType here?
wgtmac
left a comment
There was a problem hiding this comment.
I've finished a full round of review. Thanks for working on this!
| logical_type_(LogicalTypeId(descr_)) { | ||
| if (descr->sort_order() != SortOrder::UNKNOWN) { | ||
| logical_type_(LogicalTypeId(descr_)), | ||
| is_half_float_(logical_type_ == LogicalType::Type::FLOAT16) { |
There was a problem hiding this comment.
Can we remove is_half_float_ by just calling logical_type_ == LogicalType::Type::FLOAT16 where it is called? It looks weird because this variable does not apply to all cases.
| } | ||
|
|
||
| // Create stats from provided values. | ||
| // Only used by the deprecated MakeStatistics overload. Remove it after that |
| FloatingValueSummary<ArrowFloat, column_order> summary; | ||
| std::invoke(std::forward<VisitValues>(visit_values), | ||
| [&](const auto& value) { summary.Add(value); }); | ||
| if (HasNanCount() && update_nan_count) { |
There was a problem hiding this comment.
Should we clear it if update_nan_count is false?
| this->has_null_count_ = true; | ||
| // NaN counts are collected alongside floating-point bounds and enabled by | ||
| // default. | ||
| if constexpr (std::same_as<DType, FLBAType>) { |
There was a problem hiding this comment.
Why can't we call reset in all cases?
| const T positive_nan = SafeCopy<T>(positive_nan_bits); | ||
| const T negative_zero = -T{0}; | ||
| const T positive_zero = T{0}; | ||
| std::array<T, 4> mixed{negative_nan, positive_zero, negative_zero, positive_nan}; |
There was a problem hiding this comment.
It might be better if we just have only positive_zero or negative_zero but not both to verify that no adjustment is done for zero signedness. Same for float16 test below.
| ::testing::HasSubstr("BloomFilterBuilder does not support boolean type")); | ||
| } | ||
|
|
||
| TEST(ParquetPageIndex, FloatingPointOrders) { |
There was a problem hiding this comment.
BTW, I think this test case is a little bit weak. This file was added by apache/parquet-testing#104 and it contains following attributes:
» parquet-cli meta target/parquet-testing/data/floating_orders_nan_count.parquet
File path: data/floating_orders_nan_count.parquet
Created by: parquet-mr version 1.18.0-SNAPSHOT (build c5dcd8ca5bad5fde9c797b876a16b5bf3b9206c0)
Properties:
original.created.by: parquet-mr version 1.18.0-SNAPSHOT (build c5dcd8ca5bad5fde9c797b876a16b5bf3b9206c0)
writer.model.name: example
Schema:
message msg {
required float float_ieee754;
required float float_typedef;
required double double_ieee754;
required double double_typedef;
required fixed_len_byte_array(2) float16_ieee754 (FLOAT16);
required fixed_len_byte_array(2) float16_typedef (FLOAT16);
}
Row group 0: count: 10 42.20 B records start: 4 total(compressed): 422 B total(uncompressed):422 B
--------------------------------------------------------------------------------
type encodings count avg size nulls min / max
float_ieee754 FLOAT _ _ 10 6.30 B 0 "-2.0" / "5.0"
float_typedef FLOAT _ _ 10 6.30 B 0 "-2.0" / "5.0"
double_ieee754 DOUBLE _ _ 10 10.50 B 0 "-2.0" / "5.0"
double_typedef DOUBLE _ _ 10 10.50 B 0 "-2.0" / "5.0"
float16_ieee754 FIXED[2] _ _ 10 4.30 B 0 "-2.0" / "5.0"
float16_typedef FIXED[2] _ _ 10 4.30 B 0 "-2.0" / "5.0"
Row group 1: count: 10 42.20 B records start: 426 total(compressed): 422 B total(uncompressed):422 B
--------------------------------------------------------------------------------
type encodings count avg size nulls min / max
float_ieee754 FLOAT _ _ 10 6.30 B 0 "-2.0" / "3.0"
float_typedef FLOAT _ _ 10 6.30 B 0
double_ieee754 DOUBLE _ _ 10 10.50 B 0 "-2.0" / "3.0"
double_typedef DOUBLE _ _ 10 10.50 B 0
float16_ieee754 FIXED[2] _ _ 10 4.30 B 0 "-2.0" / "3.0"
float16_typedef FIXED[2] _ _ 10 4.30 B 0
Row group 2: count: 10 42.20 B records start: 848 total(compressed): 422 B total(uncompressed):422 B
--------------------------------------------------------------------------------
type encodings count avg size nulls min / max
float_ieee754 FLOAT _ _ 10 6.30 B 0 "NaN" / "NaN"
float_typedef FLOAT _ _ 10 6.30 B 0
double_ieee754 DOUBLE _ _ 10 10.50 B 0 "NaN" / "NaN"
double_typedef DOUBLE _ _ 10 10.50 B 0
float16_ieee754 FIXED[2] _ _ 10 4.30 B 0 "NaN" / "NaN"
float16_typedef FIXED[2] _ _ 10 4.30 B 0
Row group 3: count: 10 42.20 B records start: 1270 total(compressed): 422 B total(uncompressed):422 B
--------------------------------------------------------------------------------
type encodings count avg size nulls min / max
float_ieee754 FLOAT _ _ 10 6.30 B 0 "0.0" / "5.0"
float_typedef FLOAT _ _ 10 6.30 B 0 "-0.0" / "5.0"
double_ieee754 DOUBLE _ _ 10 10.50 B 0 "0.0" / "5.0"
double_typedef DOUBLE _ _ 10 10.50 B 0 "-0.0" / "5.0"
float16_ieee754 FIXED[2] _ _ 10 4.30 B 0 "0.0" / "5.0"
float16_typedef FIXED[2] _ _ 10 4.30 B 0 "-0.0" / "5.0"
Row group 4: count: 10 42.20 B records start: 1692 total(compressed): 422 B total(uncompressed):422 B
--------------------------------------------------------------------------------
type encodings count avg size nulls min / max
float_ieee754 FLOAT _ _ 10 6.30 B 0 "-5.0" / "-0.0"
float_typedef FLOAT _ _ 10 6.30 B 0 "-5.0" / "0.0"
double_ieee754 DOUBLE _ _ 10 10.50 B 0 "-5.0" / "-0.0"
double_typedef DOUBLE _ _ 10 10.50 B 0 "-5.0" / "0.0"
float16_ieee754 FIXED[2] _ _ 10 4.30 B 0 "-5.0" / "-0.0"
float16_typedef FIXED[2] _ _ 10 4.30 B 0 "-5.0" / "0.0"
It would be better to verify column chunk stats and page index of every row group and column are correctly parsed.
| const ::parquet::schema::NodePtr& parquet_node) { | ||
| auto field = ::arrow::field("x", type); | ||
| auto dataset_schema = ::arrow::schema({field}); | ||
| ::parquet::ColumnDescriptor descr(parquet_node, 0, 0); |
There was a problem hiding this comment.
These test cases still use type_defined_order and do not cover the new ieee754 total order and do not actually test the filtering logic. Does it make sense to directly use floating_orders_nan_count.parquet from parquet-testing for a real e2e interoperability test?
Rationale for this change
Implement IEEE 754 total order and NaN counts from apache/parquet-format#514.
What changes are included in this PR?
nan_countto statistics andnan_countsto PageIndex.Are these changes tested?
Yes.
Are there any user-facing changes?
cpp/src/parquet/types.h: AddsColumnOrder::IEEE_754_TOTAL_ORDER.cpp/src/parquet/properties.h: Adds the floating-point column-order writer property.cpp/src/parquet/schema.h: Allows column descriptors to use IEEE-ordered min/max statistics.cpp/src/parquet/page_index.h: Exposeshas_nan_counts()andnan_counts().cpp/src/parquet/statistics.h: Adds NaN fields and presence APIs toEncodedStatisticsandStatistics, and extends encoded-stateStatistics::Make/MakeStatisticsoverloads withnan_countandhas_nan_count.