Skip to content

Commit feb511e

Browse files
authored
fix: canonicalize floating-point NaN values (#255)
1 parent 34d0cb2 commit feb511e

16 files changed

Lines changed: 218 additions & 89 deletions

File tree

include/paimon/predicate/literal.h

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -91,13 +91,12 @@ class PAIMON_EXPORT Literal {
9191
std::string ToString() const;
9292

9393
/// Gets the hash code for this literal.
94-
/// @note HashCode() hashes the exact bit representation (including Decimal scale), while
95-
/// operator== delegates to CompareTo() which uses numeric equality (e.g. decimals with
96-
/// different scales can compare equal). This means the hash-equality contract (equal objects
97-
/// must have equal hashes) may be violated for Decimal literals with different scales. In
98-
/// practice this is safe because all current std::unordered_map<Literal, ...> usages (bitmap
99-
/// file index) only store values from the same column, which guarantees a fixed precision and
100-
/// scale.
94+
/// @note HashCode() canonicalizes all floating-point NaNs so that values considered equal by
95+
/// CompareTo() have the same hash. Decimal values include their scale in the hash, while
96+
/// CompareTo() uses numeric equality, so Decimal literals with different scales can still
97+
/// violate the hash-equality contract. In practice this is safe because all current
98+
/// std::unordered_map<Literal, ...> usages only store values from the same column, which has a
99+
/// fixed precision and scale.
101100
size_t HashCode() const;
102101

103102
/// Compares this literal with another literal. The comparison follows SQL semantics for the

src/paimon/common/data/variant/generic_variant_test.cpp

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
#include "paimon/common/data/variant/generic_variant.h"
2121

22+
#include <cstdint>
2223
#include <functional>
2324
#include <limits>
2425
#include <string>
@@ -27,6 +28,7 @@
2728
#include "gtest/gtest.h"
2829
#include "paimon/common/data/variant/variant_builder.h"
2930
#include "paimon/common/data/variant/variant_defs.h"
31+
#include "paimon/common/utils/math.h"
3032
#include "paimon/memory/memory_pool.h"
3133
#include "paimon/testing/utils/testharness.h"
3234

@@ -350,6 +352,23 @@ TEST_F(GenericVariantTest, NonFiniteDoubleToJson) {
350352
ASSERT_EQ(json, "\"Infinity\"");
351353
}
352354

355+
TEST_F(GenericVariantTest, CanonicalizesFloatingPointNaN) {
356+
{
357+
VariantBuilder builder(false);
358+
ASSERT_OK(builder.AppendFloat(FloatingPointFromBits<float>(0xffc12345U)));
359+
ASSERT_OK_AND_ASSIGN(std::shared_ptr<GenericVariant> variant, builder.Build(pool_));
360+
ASSERT_OK_AND_ASSIGN(std::string_view value, variant->Value());
361+
ASSERT_EQ(ToHex(value), "380000c07f");
362+
}
363+
{
364+
VariantBuilder builder(false);
365+
ASSERT_OK(builder.AppendDouble(FloatingPointFromBits<double>(0xfff8123456789abcULL)));
366+
ASSERT_OK_AND_ASSIGN(std::shared_ptr<GenericVariant> variant, builder.Build(pool_));
367+
ASSERT_OK_AND_ASSIGN(std::string_view value, variant->Value());
368+
ASSERT_EQ(ToHex(value), "1c000000000000f87f");
369+
}
370+
}
371+
353372
TEST_F(GenericVariantTest, GetTypeInfoReturnsHeaderBits) {
354373
// GetTypeInfo exposes the primitive header's type-info bits; 42 is encoded as an int1.
355374
auto v = FromJson("42");

src/paimon/common/data/variant/variant_builder.cpp

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030

3131
#include "fmt/format.h"
3232
#include "paimon/common/data/variant/variant_defs.h"
33+
#include "paimon/common/utils/math.h"
3334
#include "rapidjson/error/en.h"
3435
#include "rapidjson/memorystream.h"
3536
#include "rapidjson/reader.h"
@@ -339,8 +340,7 @@ Status VariantBuilder::AppendLong(int64_t l) {
339340
Status VariantBuilder::AppendDouble(double d) {
340341
PAIMON_RETURN_NOT_OK(CheckCapacity(1 + 8));
341342
write_buffer_[write_pos_++] = VariantBinaryUtil::PrimitiveHeader(VariantDefs::kDouble);
342-
int64_t bits;
343-
memcpy(&bits, &d, sizeof(bits));
343+
const int64_t bits = CanonicalizeDoubleToLongBits(d);
344344
VariantBinaryUtil::WriteLong(bits, 8, write_buffer_.data(), write_pos_);
345345
write_pos_ += 8;
346346
return Status::OK();
@@ -409,8 +409,7 @@ Status VariantBuilder::AppendTimestampNtz(int64_t micros_since_epoch) {
409409
Status VariantBuilder::AppendFloat(float f) {
410410
PAIMON_RETURN_NOT_OK(CheckCapacity(1 + 4));
411411
write_buffer_[write_pos_++] = VariantBinaryUtil::PrimitiveHeader(VariantDefs::kFloat);
412-
int32_t bits;
413-
memcpy(&bits, &f, sizeof(bits));
412+
const int32_t bits = CanonicalizeFloatToIntBits(f);
414413
VariantBinaryUtil::WriteLong(bits, 4, write_buffer_.data(), write_pos_);
415414
write_pos_ += 4;
416415
return Status::OK();

src/paimon/common/file_index/bloomfilter/fast_hash.cpp

Lines changed: 3 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
#include "paimon/common/file_index/bloomfilter/fast_hash.h"
2020

2121
#include <cassert>
22-
#include <cmath>
2322
#include <cstring>
2423
#include <string>
2524
#include <utility>
@@ -28,18 +27,14 @@
2827
#include "paimon/common/utils/checked_cast.h"
2928
#include "paimon/common/utils/date_time_utils.h"
3029
#include "paimon/common/utils/field_type_utils.h"
30+
#include "paimon/common/utils/math.h"
3131
#include "paimon/data/timestamp.h"
3232
#include "paimon/defs.h"
3333
#include "paimon/file_index/file_index_result.h"
3434
#include "paimon/status.h"
3535
#include "xxhash.h" // NOLINT(build/include_subdir)
3636

3737
namespace paimon {
38-
namespace {
39-
constexpr int32_t kCanonicalFloatNaNBits = 0x7fc00000;
40-
constexpr int64_t kCanonicalDoubleNaNBits = 0x7ff8000000000000L;
41-
} // namespace
42-
4338
Result<FastHash::HashFunction> FastHash::GetHashFunction(
4439
const std::shared_ptr<arrow::DataType>& arrow_type) {
4540
PAIMON_ASSIGN_OR_RAISE(FieldType field_type,
@@ -64,23 +59,11 @@ Result<FastHash::HashFunction> FastHash::GetHashFunction(
6459
});
6560
case FieldType::FLOAT:
6661
return HashFunction([](const Literal& literal) -> int64_t {
67-
const auto raw_value = literal.GetValue<float>();
68-
if (std::isnan(raw_value)) {
69-
return GetLongHash(kCanonicalFloatNaNBits);
70-
}
71-
int32_t bits = 0;
72-
std::memcpy(&bits, &raw_value, sizeof(raw_value));
73-
return GetLongHash(bits);
62+
return GetLongHash(CanonicalizeFloatToIntBits(literal.GetValue<float>()));
7463
});
7564
case FieldType::DOUBLE:
7665
return HashFunction([](const Literal& literal) -> int64_t {
77-
const auto raw_value = literal.GetValue<double>();
78-
if (std::isnan(raw_value)) {
79-
return GetLongHash(kCanonicalDoubleNaNBits);
80-
}
81-
int64_t bits;
82-
std::memcpy(&bits, &raw_value, sizeof(raw_value));
83-
return GetLongHash(bits);
66+
return GetLongHash(CanonicalizeDoubleToLongBits(literal.GetValue<double>()));
8467
});
8568
case FieldType::TIMESTAMP: {
8669
auto ts_type = checked_pointer_cast<arrow::TimestampType>(arrow_type);

src/paimon/common/file_index/bloomfilter/fast_hash_test.cpp

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,12 @@
2020

2121
#include <cmath>
2222
#include <cstdint>
23-
#include <cstring>
2423
#include <limits>
2524
#include <string>
2625
#include <vector>
2726

2827
#include "gtest/gtest.h"
28+
#include "paimon/common/utils/math.h"
2929
#include "paimon/data/timestamp.h"
3030
#include "paimon/defs.h"
3131
#include "paimon/file_index/file_index_result.h"
@@ -168,26 +168,16 @@ TEST_F(FastHashTest, TestCompatibleWithJava) {
168168
}
169169

170170
TEST_F(FastHashTest, TestNaNCompatibleWithJava) {
171-
auto float_from_bits = [](uint32_t bits) {
172-
float value;
173-
std::memcpy(&value, &bits, sizeof(value));
174-
return value;
175-
};
176-
const float float_nan = float_from_bits(0x7fc12345);
177-
const float negative_float_nan = float_from_bits(0xffc54321);
171+
const auto float_nan = FloatingPointFromBits<float>(0x7fc12345U);
172+
const auto negative_float_nan = FloatingPointFromBits<float>(0xffc54321U);
178173
ASSERT_TRUE(std::isnan(float_nan));
179174
ASSERT_TRUE(std::isnan(negative_float_nan));
180175
ASSERT_OK_AND_ASSIGN(auto float_hash_function, FastHash::GetHashFunction(arrow::float32()));
181176
CheckResult(float_hash_function, {Literal(float_nan), Literal(negative_float_nan)},
182177
{0x67c27c6d9936ae63, 0x67c27c6d9936ae63});
183178

184-
auto double_from_bits = [](uint64_t bits) {
185-
double value;
186-
std::memcpy(&value, &bits, sizeof(value));
187-
return value;
188-
};
189-
const double double_nan = double_from_bits(0x7ff8123456789abc);
190-
const double negative_double_nan = double_from_bits(0xfff8abcdef012345);
179+
const auto double_nan = FloatingPointFromBits<double>(0x7ff8123456789abcULL);
180+
const auto negative_double_nan = FloatingPointFromBits<double>(0xfff8abcdef012345ULL);
191181
ASSERT_TRUE(std::isnan(double_nan));
192182
ASSERT_TRUE(std::isnan(negative_double_nan));
193183
ASSERT_OK_AND_ASSIGN(auto double_hash_function, FastHash::GetHashFunction(arrow::float64()));

src/paimon/common/global_index/btree/key_serializer.cpp

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
#include "paimon/common/utils/date_time_utils.h"
2828
#include "paimon/common/utils/field_type_utils.h"
2929
#include "paimon/common/utils/fields_comparator.h"
30+
#include "paimon/common/utils/math.h"
3031
#include "paimon/common/utils/preconditions.h"
3132
#include "paimon/common/utils/var_length_int_utils.h"
3233
#include "paimon/data/decimal.h"
@@ -164,19 +165,13 @@ Result<std::shared_ptr<Bytes>> KeySerializer::SerializeKey(
164165
case FieldType::FLOAT: {
165166
MemorySliceOutput output(4, pool);
166167
output.Reset();
167-
auto fvalue = literal.GetValue<float>();
168-
int32_t ivalue;
169-
memcpy(&ivalue, &fvalue, sizeof(float));
170-
output.WriteValue<int32_t>(ivalue);
168+
output.WriteValue<int32_t>(CanonicalizeFloatToIntBits(literal.GetValue<float>()));
171169
return output.ToSlice().CopyBytes(pool);
172170
}
173171
case FieldType::DOUBLE: {
174172
MemorySliceOutput output(8, pool);
175173
output.Reset();
176-
auto dvalue = literal.GetValue<double>();
177-
int64_t ivalue;
178-
memcpy(&ivalue, &dvalue, sizeof(double));
179-
output.WriteValue<int64_t>(ivalue);
174+
output.WriteValue<int64_t>(CanonicalizeDoubleToLongBits(literal.GetValue<double>()));
180175
return output.ToSlice().CopyBytes(pool);
181176
}
182177
case FieldType::STRING: {

src/paimon/common/global_index/btree/key_serializer_test.cpp

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@
1919

2020
#include "paimon/common/global_index/btree/key_serializer.h"
2121

22+
#include <cstdint>
23+
#include <string>
24+
2225
#include "gtest/gtest.h"
26+
#include "paimon/common/utils/math.h"
2327
#include "paimon/data/decimal.h"
2428
#include "paimon/data/timestamp.h"
2529
#include "paimon/testing/utils/testharness.h"
@@ -208,6 +212,30 @@ TEST_F(KeySerializerTest, SerializeAndDeserializeAllTypes) {
208212
}
209213
}
210214

215+
TEST_F(KeySerializerTest, CanonicalizesFloatingPointNaN) {
216+
const auto float_nan = FloatingPointFromBits<float>(0xffc12345U);
217+
const auto canonical_float_nan = FloatingPointFromBits<float>(kCanonicalFloatNaNBits);
218+
ASSERT_OK_AND_ASSIGN(
219+
std::shared_ptr<Bytes> float_bytes,
220+
KeySerializer::SerializeKey(Literal(float_nan), arrow::float32(), pool_.get()));
221+
ASSERT_OK_AND_ASSIGN(
222+
std::shared_ptr<Bytes> canonical_float_bytes,
223+
KeySerializer::SerializeKey(Literal(canonical_float_nan), arrow::float32(), pool_.get()));
224+
ASSERT_EQ(std::string(float_bytes->data(), float_bytes->size()),
225+
std::string(canonical_float_bytes->data(), canonical_float_bytes->size()));
226+
227+
const auto double_nan = FloatingPointFromBits<double>(0xfff8123456789abcULL);
228+
const auto canonical_double_nan = FloatingPointFromBits<double>(kCanonicalDoubleNaNBits);
229+
ASSERT_OK_AND_ASSIGN(
230+
std::shared_ptr<Bytes> double_bytes,
231+
KeySerializer::SerializeKey(Literal(double_nan), arrow::float64(), pool_.get()));
232+
ASSERT_OK_AND_ASSIGN(
233+
std::shared_ptr<Bytes> canonical_double_bytes,
234+
KeySerializer::SerializeKey(Literal(canonical_double_nan), arrow::float64(), pool_.get()));
235+
ASSERT_EQ(std::string(double_bytes->data(), double_bytes->size()),
236+
std::string(canonical_double_bytes->data(), canonical_double_bytes->size()));
237+
}
238+
211239
TEST_F(KeySerializerTest, RejectsMalformedSerializedKeys) {
212240
auto wrap = [this](const std::string& value) {
213241
return MemorySlice::Wrap(std::make_shared<Bytes>(value, pool_.get()));

src/paimon/common/global_index/global_index_result.cpp

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
#include "fmt/format.h"
2323
#include "paimon/common/io/memory_segment_output_stream.h"
2424
#include "paimon/common/memory/memory_segment_utils.h"
25+
#include "paimon/common/utils/math.h"
2526
#include "paimon/global_index/bitmap_global_index_result.h"
2627
#include "paimon/global_index/bitmap_scored_global_index_result.h"
2728
#include "paimon/io/byte_array_input_stream.h"
@@ -37,8 +38,8 @@ void WriteBitmapAndScores(const RoaringBitmap64* bitmap, const std::vector<float
3738
out->WriteBytes(bitmap_bytes);
3839

3940
out->WriteValue<int32_t>(scores.size());
40-
for (auto score : scores) {
41-
out->WriteValue<float>(score);
41+
for (float score : scores) {
42+
out->WriteValue<float>(CanonicalizeFloatingPoint(score));
4243
}
4344
}
4445

src/paimon/common/global_index/global_index_result_test.cpp

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,12 @@
1919

2020
#include "paimon/global_index/global_index_result.h"
2121

22+
#include <cmath>
23+
#include <cstdint>
2224
#include <utility>
2325

2426
#include "gtest/gtest.h"
27+
#include "paimon/common/utils/math.h"
2528
#include "paimon/global_index/bitmap_global_index_result.h"
2629
#include "paimon/global_index/bitmap_scored_global_index_result.h"
2730
#include "paimon/testing/utils/testharness.h"
@@ -144,6 +147,29 @@ TEST_F(GlobalIndexResultTest, TestSerializeAndDeserializeWithScore) {
144147
serialize_bytes->data() + serialize_bytes->size()));
145148
}
146149

150+
TEST_F(GlobalIndexResultTest, TestSerializeCanonicalizesNaNScore) {
151+
auto pool = GetDefaultPool();
152+
const auto payload_nan = FloatingPointFromBits<float>(0xffc12345U);
153+
const auto canonical_nan = FloatingPointFromBits<float>(kCanonicalFloatNaNBits);
154+
auto index_result = std::make_shared<BitmapScoredGlobalIndexResult>(
155+
RoaringBitmap64::From({1}), std::vector<float>{payload_nan});
156+
auto canonical_index_result = std::make_shared<BitmapScoredGlobalIndexResult>(
157+
RoaringBitmap64::From({1}), std::vector<float>{canonical_nan});
158+
159+
ASSERT_OK_AND_ASSIGN(PAIMON_UNIQUE_PTR<Bytes> serialized,
160+
GlobalIndexResult::Serialize(index_result, pool));
161+
ASSERT_OK_AND_ASSIGN(PAIMON_UNIQUE_PTR<Bytes> canonical_serialized,
162+
GlobalIndexResult::Serialize(canonical_index_result, pool));
163+
ASSERT_EQ(*serialized, *canonical_serialized);
164+
165+
ASSERT_OK_AND_ASSIGN(
166+
std::shared_ptr<GlobalIndexResult> deserialized,
167+
GlobalIndexResult::Deserialize(serialized->data(), serialized->size(), pool));
168+
auto scored_result = std::dynamic_pointer_cast<BitmapScoredGlobalIndexResult>(deserialized);
169+
ASSERT_TRUE(scored_result);
170+
ASSERT_TRUE(std::isnan(scored_result->GetScores()[0]));
171+
}
172+
147173
TEST_F(GlobalIndexResultTest, TestInvalidSerialize) {
148174
auto pool = GetDefaultPool();
149175
auto result = std::make_shared<FakeGlobalIndexResult>(std::vector<int64_t>({1, 3, 5, 100}));

src/paimon/common/predicate/literal.cpp

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818

1919
#include "paimon/predicate/literal.h"
2020

21-
#include <cmath>
2221
#include <cstring>
2322
#include <functional>
2423
#include <sstream>
@@ -29,6 +28,7 @@
2928
#include "fmt/format.h"
3029
#include "paimon/common/utils/field_type_utils.h"
3130
#include "paimon/common/utils/fields_comparator.h"
31+
#include "paimon/common/utils/math.h"
3232
#include "paimon/data/decimal.h"
3333
#include "paimon/data/timestamp.h"
3434
#include "paimon/status.h"
@@ -63,9 +63,9 @@ class Literal::Impl {
6363
case FieldType::BIGINT:
6464
return std::hash<int64_t>{}(value_.BigIntVal);
6565
case FieldType::FLOAT:
66-
return std::hash<float>{}(value_.FloatVal);
66+
return std::hash<float>{}(CanonicalizeFloatingPoint(value_.FloatVal));
6767
case FieldType::DOUBLE:
68-
return std::hash<double>{}(value_.DoubleVal);
68+
return std::hash<double>{}(CanonicalizeFloatingPoint(value_.DoubleVal));
6969
case FieldType::STRING:
7070
case FieldType::BINARY:
7171
return std::hash<std::string_view>{}(std::string_view(value_.Buffer, size_));

0 commit comments

Comments
 (0)