diff --git a/cpp/src/arrow/CMakeLists.txt b/cpp/src/arrow/CMakeLists.txt index e792574a183..4cbc441b4b3 100644 --- a/cpp/src/arrow/CMakeLists.txt +++ b/cpp/src/arrow/CMakeLists.txt @@ -818,6 +818,7 @@ if(ARROW_COMPUTE) compute/kernels/scalar_arithmetic.cc compute/kernels/scalar_boolean.cc compute/kernels/scalar_compare.cc + compute/kernels/scalar_hash.cc compute/kernels/scalar_if_else.cc compute/kernels/scalar_nested.cc compute/kernels/scalar_random.cc diff --git a/cpp/src/arrow/compute/CMakeLists.txt b/cpp/src/arrow/compute/CMakeLists.txt index e965b89ec05..7af66cf19ce 100644 --- a/cpp/src/arrow/compute/CMakeLists.txt +++ b/cpp/src/arrow/compute/CMakeLists.txt @@ -181,6 +181,7 @@ add_arrow_compute_test(row_test arrow_compute_testing) add_arrow_compute_benchmark(function_benchmark) +add_arrow_compute_benchmark(key_hash_benchmark) add_subdirectory(kernels) diff --git a/cpp/src/arrow/compute/api_scalar.cc b/cpp/src/arrow/compute/api_scalar.cc index 0aa8fd757a9..ba1f20f6eb0 100644 --- a/cpp/src/arrow/compute/api_scalar.cc +++ b/cpp/src/arrow/compute/api_scalar.cc @@ -957,6 +957,16 @@ Result MapLookup(const Datum& arg, MapLookupOptions options, ExecContext* return CallFunction("map_lookup", {arg}, &options, ctx); } +// ---------------------------------------------------------------------- +// Hash functions +Result Hash32(const Datum& input_array, ExecContext* ctx) { + return CallFunction("hash32", {input_array}, ctx); +} + +Result Hash64(const Datum& input_array, ExecContext* ctx) { + return CallFunction("hash64", {input_array}, ctx); +} + // ---------------------------------------------------------------------- } // namespace compute diff --git a/cpp/src/arrow/compute/api_scalar.h b/cpp/src/arrow/compute/api_scalar.h index c4238b956c9..9a3233202a0 100644 --- a/cpp/src/arrow/compute/api_scalar.h +++ b/cpp/src/arrow/compute/api_scalar.h @@ -1807,5 +1807,44 @@ ARROW_EXPORT Result NanosecondsBetween(const Datum& left, const Datum& ri /// \note API not yet finalized ARROW_EXPORT Result MapLookup(const Datum& map, MapLookupOptions options, ExecContext* ctx = NULLPTR); + +/// \brief Construct a hash value for each row of the input. +/// +/// The result has the same length and shape as the input (Array in, Array out; +/// ChunkedArray in, ChunkedArray out), but with element type UInt32. For a nested +/// input type (struct, list, map, etc.), each row's child values are combined into a +/// single hash for that row, recursively. Null rows hash to a fixed sentinel value, +/// never to null (the output itself is never null). Hash values are not guaranteed to +/// be stable across different versions of the library, and this function does not +/// currently take options, though these may be added in the future. +/// +/// \param[in] input_array input data to hash +/// \param[in] ctx function execution context, optional +/// \return elementwise hash values +/// +/// \since 26.0.0 +/// \note API not yet finalized +ARROW_EXPORT +Result Hash32(const Datum& input_array, ExecContext* ctx = NULLPTR); + +/// \brief Construct a hash value for each row of the input. +/// +/// The result has the same length and shape as the input (Array in, Array out; +/// ChunkedArray in, ChunkedArray out), but with element type UInt64. For a nested +/// input type (struct, list, map, etc.), each row's child values are combined into a +/// single hash for that row, recursively. Null rows hash to a fixed sentinel value, +/// never to null (the output itself is never null). Hash values are not guaranteed to +/// be stable across different versions of the library, and this function does not +/// currently take options, though these may be added in the future. +/// +/// \param[in] input_array input data to hash +/// \param[in] ctx function execution context, optional +/// \return elementwise hash values +/// +/// \since 26.0.0 +/// \note API not yet finalized +ARROW_EXPORT +Result Hash64(const Datum& input_array, ExecContext* ctx = NULLPTR); + } // namespace compute } // namespace arrow diff --git a/cpp/src/arrow/compute/initialize.cc b/cpp/src/arrow/compute/initialize.cc index d88835da04a..ec386aa5e3e 100644 --- a/cpp/src/arrow/compute/initialize.cc +++ b/cpp/src/arrow/compute/initialize.cc @@ -31,6 +31,7 @@ Status RegisterComputeKernels() { internal::RegisterScalarArithmetic(registry); internal::RegisterScalarBoolean(registry); internal::RegisterScalarComparison(registry); + internal::RegisterScalarHash(registry); internal::RegisterScalarIfElse(registry); internal::RegisterScalarNested(registry); internal::RegisterScalarRandom(registry); // Nullary diff --git a/cpp/src/arrow/compute/kernels/CMakeLists.txt b/cpp/src/arrow/compute/kernels/CMakeLists.txt index 15955b5ef88..3fc21a91bdc 100644 --- a/cpp/src/arrow/compute/kernels/CMakeLists.txt +++ b/cpp/src/arrow/compute/kernels/CMakeLists.txt @@ -77,6 +77,7 @@ add_arrow_compute_test(scalar_math_test add_arrow_compute_test(scalar_utility_test SOURCES + scalar_hash_test.cc scalar_random_test.cc scalar_set_lookup_test.cc scalar_validity_test.cc @@ -89,6 +90,7 @@ add_arrow_benchmark(scalar_cast_benchmark PREFIX "arrow-compute") add_arrow_compute_benchmark(scalar_arithmetic_benchmark) add_arrow_compute_benchmark(scalar_boolean_benchmark) add_arrow_compute_benchmark(scalar_compare_benchmark) +add_arrow_compute_benchmark(scalar_hash_benchmark) add_arrow_compute_benchmark(scalar_if_else_benchmark) add_arrow_compute_benchmark(scalar_list_benchmark) add_arrow_compute_benchmark(scalar_random_benchmark) diff --git a/cpp/src/arrow/compute/kernels/scalar_hash.cc b/cpp/src/arrow/compute/kernels/scalar_hash.cc new file mode 100644 index 00000000000..b0b69be752e --- /dev/null +++ b/cpp/src/arrow/compute/kernels/scalar_hash.cc @@ -0,0 +1,384 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +#include +#include + +#include "arrow/array/array_base.h" +#include "arrow/array/builder_primitive.h" +#include "arrow/compute/kernels/common_internal.h" +#include "arrow/compute/key_hash_internal.h" +#include "arrow/compute/light_array_internal.h" +#include "arrow/compute/registry_internal.h" +#include "arrow/compute/util.h" +#include "arrow/result.h" + +namespace arrow { +namespace compute { +namespace internal { + +// Define symbols visible within `arrow::compute::internal` in this file; +// these symbols are not visible outside of this file. +namespace { + +// ------------------------------ +// Kernel implementations +// It is expected that HashArrowType is either UInt32Type or UInt64Type (default) + +// Not dependent on the ArrowType/Hasher template arguments below, so defined +// as a free function to avoid unnecessary code generation per instantiation. +// Never called with a nested (list-like or struct) array: HashArray handles those +// itself, either via HashChild (which reduces them to a plain UInt32/UInt64 array +// before it ever reaches here) or the is_list_like branch directly. +Result ToColumnArray(const ArraySpan& array) { + KeyColumnMetadata metadata; + const uint8_t* validity_buffer = nullptr; + const uint8_t* fixed_length_buffer = nullptr; + const uint8_t* var_length_buffer = nullptr; + + if (array.GetBuffer(0) != nullptr) { + validity_buffer = array.GetBuffer(0)->data(); + } + if (array.GetBuffer(1) != nullptr) { + fixed_length_buffer = array.GetBuffer(1)->data(); + } + + auto type = array.type; + auto type_id = type->id(); + if (type_id == Type::NA) { + metadata = KeyColumnMetadata(true, 0, true); + } else if (type_id == Type::BOOL) { + metadata = KeyColumnMetadata(true, 0); + } else if (is_fixed_width(type_id)) { + metadata = KeyColumnMetadata(true, type->bit_width() / 8); + } else if (is_binary_like(type_id)) { + metadata = KeyColumnMetadata(false, sizeof(uint32_t)); + if (array.GetBuffer(2) != nullptr) { + var_length_buffer = array.GetBuffer(2)->data(); + } + } else if (is_large_binary_like(type_id)) { + metadata = KeyColumnMetadata(false, sizeof(uint64_t)); + if (array.GetBuffer(2) != nullptr) { + var_length_buffer = array.GetBuffer(2)->data(); + } + } else { + return Status::TypeError("Unsupported column data type ", type->name(), + " used with hash32/hash64 compute kernel"); + } + + return KeyColumnArray(metadata, array.length, validity_buffer, fixed_length_buffer, + var_length_buffer); +} + +// Zeroes out[i] wherever array is null. +template +void ZeroNulls(const ArraySpan& array, c_type* out) { + if (array.GetBuffer(0) == nullptr) { + return; + } + for (int64_t i = 0; i < array.length; i++) { + if (array.IsNull(i)) { + out[i] = 0; + } + } +} + +// Folds one row's child hashes into a single hash. Seeded with CombineHashes(0, 0), +// not 0, so an empty list doesn't collide with a null list (zeroed separately below). +// Free function since it only depends on c_type/Hasher, not ArrowType. +template +c_type CombineRange(const c_type* value_hashes, int64_t start, int64_t end) { + c_type combined = Hasher::CombineHashes(0, 0); + for (int64_t j = start; j < end; j++) { + combined = Hasher::CombineHashes(combined, value_hashes[j]); + } + return combined; +} + +// Combines rows for LIST/LARGE_LIST/MAP, whose offsets buffers differ only in width. +// `bias` is values.offset + rel_start, so offsets[i] - bias locates row i. +template +void CombineOffsetRows(int64_t length, const OffsetT* offsets, int64_t bias, + const c_type* value_hash_data, c_type* out) { + for (int64_t i = 0; i < length; i++) { + out[i] = CombineRange(value_hash_data, offsets[i] - bias, + offsets[i + 1] - bias); + } +} + +template +struct FastHashScalar { + using c_type = typename ArrowType::c_type; + + // Hashes the [offset, offset + length) slice of `child`. + static Result> HashChild(const ArraySpan& child, + int64_t offset, int64_t length, + LightContext* hash_ctx, + MemoryPool* memory_pool) { + auto sliced = child; + sliced.SetSlice(offset, length); + auto arrow_type = TypeTraits::type_singleton(); + auto buffer_size = sliced.length * sizeof(c_type); + ARROW_ASSIGN_OR_RAISE(auto buffer, AllocateBuffer(buffer_size, memory_pool)); + ARROW_RETURN_NOT_OK( + HashArray(sliced, hash_ctx, memory_pool, buffer->mutable_data_as())); + // No validity buffer needed: HashArray already zeroed out[i] for every + // genuinely-null row of `sliced` above (via ZeroNulls or the valid-0 remap, both + // of which correctly use sliced's own offset), so null-ness is already fully + // encoded in the hash values themselves. A reused validity buffer would need + // rebasing anyway, since it's unshifted (reading row i needs bit `sliced.offset + + // i`) while this returned ArrayData has offset 0 and callers read its buffers + // directly as row-0-based (see ToColumnArray). + return ArrayData::Make(arrow_type, sliced.length, {nullptr, std::move(buffer)}, + /*null_count=*/0); + } + + static Status HashStructArray(const ArraySpan& array, LightContext* hash_ctx, + MemoryPool* memory_pool, c_type* out) { + if (array.child_data.empty()) { + // No fields to combine (e.g. struct<>): HashMultiColumn requires at least one + // column (it reads cols[0] unconditionally), so give every row the same defined + // hash here instead, then let ZeroNulls below zero out the actually-null rows. + c_type empty_struct_hash = Hasher::CombineHashes(0, 0); + for (int64_t i = 0; i < array.length; i++) { + out[i] = empty_struct_hash; + } + ZeroNulls(array, out); + return Status::OK(); + } + std::vector> child_hashes(array.child_data.size()); + std::vector columns(array.child_data.size()); + KeyColumnArray column; + for (size_t i = 0; i < array.child_data.size(); i++) { + auto child = array.child_data[i]; + // `child` may have its own offset independent of the struct's (a struct field + // can itself be a slice; see StructArray::GetFlattenedField), so both compose: + // struct row r reads child row (child.offset + array.offset + r). + if (is_nested(child.type->id())) { + // StructArray::Slice() doesn't reslice child_data, so `child` may be far + // larger than what this slice of `array` references. Hash only the + // referenced range instead of all of `child` (same idea as the list/map + // paths above). + ARROW_ASSIGN_OR_RAISE(child_hashes[i], + HashChild(child, child.offset + array.offset, array.length, + hash_ctx, memory_pool)); + ARROW_ASSIGN_OR_RAISE(column, ToColumnArray(*child_hashes[i])); + // child_hashes[i] already covers exactly [0, array.length): no further slice. + columns[i] = column.Slice(0, array.length); + } else { + ARROW_ASSIGN_OR_RAISE(column, ToColumnArray(child)); + columns[i] = column.Slice(child.offset + array.offset, array.length); + } + } + Hasher::HashMultiColumn(columns, hash_ctx, out); + // A null struct row's children may still look valid, so force 0 explicitly. + ZeroNulls(array, out); + return Status::OK(); + } + + // Handles FIXED_SIZE_LIST, LARGE_LIST, LIST, and MAP. `offsets` is null for + // FIXED_SIZE_LIST, which uses `list_size` as a constant stride instead. HashArray + // computes rel_start/rel_end (the range of `values` actually referenced, since + // ArrayData::Slice() doesn't slice child_data) since it already branches on type_id. + template + static Status HashListArray(const ArraySpan& array, int64_t list_size, + const OffsetT* offsets, int64_t rel_start, int64_t rel_end, + LightContext* hash_ctx, MemoryPool* memory_pool, + c_type* out) { + auto values = array.child_data[0]; + ARROW_ASSIGN_OR_RAISE(auto value_hashes, + HashChild(values, values.offset + rel_start, + rel_end - rel_start, hash_ctx, memory_pool)); + const c_type* value_hash_data = value_hashes->buffers[1]->data_as(); + // value_hash_data[k] corresponds to original row (values.offset + rel_start + k). + + // Fold every row, then zero nulls separately below; benchmarking showed this beats + // branchless masking (masking costs every row; branch prediction handles nulls free). + if (offsets != nullptr) { + CombineOffsetRows(array.length, offsets, values.offset + rel_start, + value_hash_data, out); + } else { + for (int64_t i = 0; i < array.length; i++) { + int64_t start = (array.offset + i) * list_size - values.offset - rel_start; + out[i] = CombineRange(value_hash_data, start, start + list_size); + } + } + // Required: a null row's offset range isn't guaranteed empty, and even an empty + // one would collide with CombineRange's seed for a real empty list. + ZeroNulls(array, out); + return Status::OK(); + } + + // Routes to the per-shape hashing routine for `array`'s type. + static Status HashArray(const ArraySpan& array, LightContext* hash_ctx, + MemoryPool* memory_pool, c_type* out) { + auto type_id = array.type->id(); + if (type_id == Type::EXTENSION) { + auto extension_type = checked_cast(array.type); + auto storage_array = array; + storage_array.type = extension_type->storage_type().get(); + return HashArray(storage_array, hash_ctx, memory_pool, out); + } else if (type_id == Type::STRUCT) { + return HashStructArray(array, hash_ctx, memory_pool, out); + } else if (type_id == Type::FIXED_SIZE_LIST) { + auto list_size = checked_cast(array.type)->list_size(); + auto values = array.child_data[0]; + int64_t rel_start = 0, rel_end = 0; + if (array.length > 0) { + rel_start = array.offset * list_size - values.offset; + rel_end = (array.offset + array.length) * list_size - values.offset; + } + return HashListArray(array, list_size, nullptr, rel_start, rel_end, + hash_ctx, memory_pool, out); + } else if (type_id == Type::LARGE_LIST) { + auto offsets = array.GetValues(1); + auto values = array.child_data[0]; + int64_t rel_start = 0, rel_end = 0; + if (array.length > 0) { + rel_start = offsets[0] - values.offset; + rel_end = offsets[array.length] - values.offset; + } + return HashListArray(array, 0, offsets, rel_start, rel_end, hash_ctx, + memory_pool, out); + } else if (is_list_like(type_id)) { + // LIST and MAP both use 32-bit offsets. + auto offsets = array.GetValues(1); + auto values = array.child_data[0]; + int64_t rel_start = 0, rel_end = 0; + if (array.length > 0) { + rel_start = offsets[0] - values.offset; + rel_end = offsets[array.length] - values.offset; + } + return HashListArray(array, 0, offsets, rel_start, rel_end, hash_ctx, + memory_pool, out); + } else { + KeyColumnArray column; + ARROW_ASSIGN_OR_RAISE(column, ToColumnArray(array)); + std::vector columns{column.Slice(array.offset, array.length)}; + Hasher::HashMultiColumn(columns, hash_ctx, out); + // HashIntImp (used for fixed-width types up to 8 bytes) doesn't special-case an + // all-zero-bits key (e.g. integer 0, float 0.0), so it can legitimately produce + // the same 0 that HashMultiColumn uses as the null sentinel for actually-null + // rows. Remap valid rows that collide with 0 here, scoped to this kernel's + // output, rather than changing the shared hashing engine used by hash-join and + // group-by too. + for (int64_t i = 0; i < array.length; i++) { + if (out[i] == 0 && array.IsValid(i)) { + out[i] = Hasher::CombineHashes(0, 0); + } + } + return Status::OK(); + } + } + + static Status Exec(KernelContext* ctx, const ExecSpan& input_arg, ExecResult* out) { + ARROW_DCHECK_EQ(input_arg.num_values(), 1); + ARROW_DCHECK(input_arg[0].is_array()); + ArraySpan hash_input = input_arg[0].array; + + auto exec_ctx = default_exec_context(); + if (ctx && ctx->exec_context()) { + exec_ctx = ctx->exec_context(); + } + + // Initialize stack-based memory allocator used by Hashing32 and Hashing64 + util::TempVectorStack stack_memallocator; + ARROW_RETURN_NOT_OK(stack_memallocator.Init(exec_ctx->memory_pool(), + Hasher::kHashBatchTempStackUsage)); + + // Prepare context used by Hashing32 and Hashing64 + LightContext hash_ctx; + hash_ctx.hardware_flags = exec_ctx->cpu_info()->hardware_flags(); + hash_ctx.stack = &stack_memallocator; + + // Call the hashing function, overloaded based on OutputCType + ArraySpan* result_span = out->array_span_mutable(); + c_type* result_ptr = result_span->GetValues(1); + ARROW_RETURN_NOT_OK( + HashArray(hash_input, &hash_ctx, exec_ctx->memory_pool(), result_ptr)); + + return Status::OK(); + } +}; + +class HashableMatcher : public TypeMatcher { + public: + HashableMatcher() {} + + bool Matches(const DataType& type) const override { + // Unwrap extension types (recursively, in case of nesting) so a union/view/REE + // storage type is rejected here too, rather than passing dispatch and failing + // later with a raw TypeError from HashArray/ToColumnArray. + const DataType* physical_type = &type; + while (physical_type->id() == Type::EXTENSION) { + physical_type = + checked_cast(*physical_type).storage_type().get(); + } + return !(is_union(*physical_type) || is_binary_view_like(*physical_type) || + is_list_view(*physical_type) || + physical_type->id() == Type::RUN_END_ENCODED); + } + + bool Equals(const TypeMatcher& other) const override { + if (this == &other) { + return true; + } + auto casted = dynamic_cast(&other); + return casted != nullptr; + } + + std::string ToString() const override { return "hashable"; } +}; + +const FunctionDoc hash32_doc{ + "Construct a hash for every element of the input argument", + ("This function is not suitable for cryptographic purposes.\n" + "Hash results are 32-bit and emitted for each row, including NULLs."), + {"hash_input"}}; + +const FunctionDoc hash64_doc{ + "Construct a hash for every element of the input argument", + ("This function is not suitable for cryptographic purposes.\n" + "Hash results are 64-bit and emitted for each row, including NULLs."), + {"hash_input"}}; + +} // namespace + +void RegisterScalarHash(FunctionRegistry* registry) { + // Create hash32 and hash64 function instances + auto hash32 = std::make_shared("hash32", Arity::Unary(), hash32_doc); + auto hash64 = std::make_shared("hash64", Arity::Unary(), hash64_doc); + + // Add 32-bit and 64-bit kernels to hash32 and hash64 functions + auto type_matcher = std::make_shared(); + ScalarKernel kernel32({InputType(type_matcher)}, OutputType(uint32()), + FastHashScalar::Exec); + ScalarKernel kernel64({InputType(type_matcher)}, OutputType(uint64()), + FastHashScalar::Exec); + kernel32.null_handling = NullHandling::OUTPUT_NOT_NULL; + kernel64.null_handling = NullHandling::OUTPUT_NOT_NULL; + ARROW_DCHECK_OK(hash32->AddKernel(std::move(kernel32))); + ARROW_DCHECK_OK(hash64->AddKernel(std::move(kernel64))); + + // Register hash32 and hash64 functions + ARROW_DCHECK_OK(registry->AddFunction(std::move(hash32))); + ARROW_DCHECK_OK(registry->AddFunction(std::move(hash64))); +} + +} // namespace internal +} // namespace compute +} // namespace arrow diff --git a/cpp/src/arrow/compute/kernels/scalar_hash_benchmark.cc b/cpp/src/arrow/compute/kernels/scalar_hash_benchmark.cc new file mode 100644 index 00000000000..b778c6e6e7b --- /dev/null +++ b/cpp/src/arrow/compute/kernels/scalar_hash_benchmark.cc @@ -0,0 +1,252 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +#include +#include +#include +#include +#include +#include + +#include "benchmark/benchmark.h" + +#include "arrow/testing/gtest_util.h" +#include "arrow/testing/random.h" +#include "arrow/util/hashing.h" + +#include "arrow/array/array_nested.h" +#include "arrow/compute/exec.h" + +namespace arrow { +namespace internal { + +// ------------------------------ +// Anonymous namespace with global params + +namespace { +// copied from scalar_string_benchmark +constexpr auto kSeed = 0x94378165; +constexpr double null_prob = 0.2; + +static random::RandomArrayGenerator hashing_rng(kSeed); +} // namespace + +// ------------------------------ +// Convenience functions + +static Result> MakeStructArray(int64_t n_values, + int32_t min_strlen, + int32_t max_strlen) { + auto vals_first = hashing_rng.Int64(n_values, 0, std::numeric_limits::max()); + auto vals_second = hashing_rng.String(n_values, min_strlen, max_strlen, null_prob); + auto vals_third = hashing_rng.Int64(n_values, 0, std::numeric_limits::max()); + + return arrow::StructArray::Make( + arrow::ArrayVector{vals_first, vals_second, vals_third}, + arrow::FieldVector{arrow::field("first", arrow::int64()), + arrow::field("second", arrow::utf8()), + arrow::field("third", arrow::int64())}); +} + +// ------------------------------ +// Benchmark implementations + +static void Hash64Int64(benchmark::State& state) { // NOLINT non-const reference + auto test_vals = hashing_rng.Int64(10000, 0, std::numeric_limits::max()); + + while (state.KeepRunning()) { + Datum hash_result = compute::CallFunction("hash64", {test_vals}).ValueOrDie(); + benchmark::DoNotOptimize(hash_result); + } + + state.SetBytesProcessed(state.iterations() * test_vals->length() * sizeof(int64_t)); + state.SetItemsProcessed(state.iterations() * test_vals->length()); +} + +static void Hash64StructSmallStrings( + benchmark::State& state) { // NOLINT non-const reference + ASSERT_OK_AND_ASSIGN(std::shared_ptr values_array, + MakeStructArray(10000, 2, 20)); + + // 2nd column (index 1) is a string column, which has offset type of int32_t + ASSERT_OK_AND_ASSIGN(std::shared_ptr values_second, + values_array->GetFlattenedField(1)); + auto str_vals = std::static_pointer_cast(values_second); + int32_t total_string_size = str_vals->total_values_length(); + + while (state.KeepRunning()) { + Datum hash_result = compute::CallFunction("hash64", {values_array}).ValueOrDie(); + benchmark::DoNotOptimize(hash_result); + } + + state.SetBytesProcessed(state.iterations() * + ((values_array->length() * sizeof(int64_t)) + + (total_string_size) + + (values_array->length() * sizeof(int64_t)))); + state.SetItemsProcessed(state.iterations() * 3 * values_array->length()); +} + +static void Hash64StructMediumStrings( + benchmark::State& state) { // NOLINT non-const reference + ASSERT_OK_AND_ASSIGN(std::shared_ptr values_array, + MakeStructArray(10000, 20, 120)); + + // 2nd column (index 1) is a string column, which has offset type of int32_t + ASSERT_OK_AND_ASSIGN(std::shared_ptr values_second, + values_array->GetFlattenedField(1)); + auto str_vals = std::static_pointer_cast(values_second); + int32_t total_string_size = str_vals->total_values_length(); + + while (state.KeepRunning()) { + Datum hash_result = compute::CallFunction("hash64", {values_array}).ValueOrDie(); + benchmark::DoNotOptimize(hash_result); + } + + state.SetBytesProcessed(state.iterations() * + ((values_array->length() * sizeof(int64_t)) + + (total_string_size) + + (values_array->length() * sizeof(int64_t)))); + state.SetItemsProcessed(state.iterations() * 3 * values_array->length()); +} + +static void Hash64StructLargeStrings( + benchmark::State& state) { // NOLINT non-const reference + ASSERT_OK_AND_ASSIGN(std::shared_ptr values_array, + MakeStructArray(10000, 120, 2000)); + + // 2nd column (index 1) is a string column, which has offset type of int32_t + ASSERT_OK_AND_ASSIGN(std::shared_ptr values_second, + values_array->GetFlattenedField(1)); + auto str_vals = std::static_pointer_cast(values_second); + int32_t total_string_size = str_vals->total_values_length(); + + while (state.KeepRunning()) { + Datum hash_result = compute::CallFunction("hash64", {values_array}).ValueOrDie(); + benchmark::DoNotOptimize(hash_result); + } + + state.SetBytesProcessed(state.iterations() * + ((values_array->length() * sizeof(int64_t)) + + (total_string_size) + + (values_array->length() * sizeof(int64_t)))); + state.SetItemsProcessed(state.iterations() * 3 * values_array->length()); +} + +static void Hash64ListInt64(benchmark::State& state) { // NOLINT non-const reference + constexpr int64_t test_size = 10000; + auto test_vals = hashing_rng.ArrayOf(list(int64()), test_size, null_prob); + + while (state.KeepRunning()) { + Datum hash_result = compute::CallFunction("hash64", {test_vals}).ValueOrDie(); + benchmark::DoNotOptimize(hash_result); + } + + state.SetItemsProcessed(state.iterations() * test_size); +} + +// Hashing a small slice of a much larger list array; child_data isn't sliced by +// ArrayData::Slice(), so this used to cost proportionally to the whole array. +static void Hash64ListInt64HeavilySliced( + benchmark::State& state) { // NOLINT non-const reference + constexpr int64_t total_size = 1000000; + constexpr int64_t slice_size = 100; + auto test_vals = hashing_rng.ArrayOf(list(int64()), total_size, null_prob); + auto sliced = test_vals->Slice(total_size / 2, slice_size); + + while (state.KeepRunning()) { + Datum hash_result = compute::CallFunction("hash64", {sliced}).ValueOrDie(); + benchmark::DoNotOptimize(hash_result); + } + + state.SetItemsProcessed(state.iterations() * slice_size); +} + +// Unlike lists (see Hash64ListInt64HeavilySliced), binary-like arrays scale with the +// slice, not the underlying array: KeyColumnArray::Slice() narrows the offsets buffer +// itself, so there's no unsliced "child" to worry about. +static void Hash64StringHeavilySliced( + benchmark::State& state) { // NOLINT non-const reference + constexpr int64_t total_size = 1000000; + constexpr int64_t slice_size = 100; + auto test_vals = hashing_rng.String(total_size, 2, 20, null_prob); + auto sliced = test_vals->Slice(total_size / 2, slice_size); + + while (state.KeepRunning()) { + Datum hash_result = compute::CallFunction("hash64", {sliced}).ValueOrDie(); + benchmark::DoNotOptimize(hash_result); + } + + state.SetItemsProcessed(state.iterations() * slice_size); +} + +// Same idea as Hash64ListInt64HeavilySliced, but for a nested field within a struct: +// StructArray::Slice() also doesn't reslice child_data, so hashing a small slice of a +// struct with a nested list field used to cost proportionally to the whole array. +static void Hash64StructWithNestedListHeavilySliced( + benchmark::State& state) { // NOLINT non-const reference + constexpr int64_t total_size = 1000000; + constexpr int64_t slice_size = 100; + auto lists = hashing_rng.ArrayOf(list(int64()), total_size, null_prob); + ASSERT_OK_AND_ASSIGN(auto struct_arr, + StructArray::Make({lists}, {field("f0", list(int64()))})); + auto sliced = struct_arr->Slice(total_size / 2, slice_size); + + while (state.KeepRunning()) { + Datum hash_result = compute::CallFunction("hash64", {sliced}).ValueOrDie(); + benchmark::DoNotOptimize(hash_result); + } + + state.SetItemsProcessed(state.iterations() * slice_size); +} + +static void Hash64Map(benchmark::State& state) { // NOLINT non-const reference + constexpr int64_t test_size = 10000; + auto test_keys = hashing_rng.String(test_size, 2, 20, /*null_probability=*/0); + auto test_vals = hashing_rng.Int64(test_size, 0, std::numeric_limits::max()); + auto test_keyvals = hashing_rng.Map(test_keys, test_vals, test_size); + + auto key_arr = std::static_pointer_cast(test_keys); + int32_t total_key_size = key_arr->total_values_length(); + int32_t total_val_size = test_size * sizeof(int64_t); + + while (state.KeepRunning()) { + Datum hash_result = compute::CallFunction("hash64", {test_keyvals}).ValueOrDie(); + benchmark::DoNotOptimize(hash_result); + } + + state.SetBytesProcessed(state.iterations() * (total_key_size + total_val_size)); + state.SetItemsProcessed(state.iterations() * 2 * test_size); +} + +// ------------------------------ +// Benchmark declarations + +// Uses "FastHash" compute functions (wraps KeyHash functions) +BENCHMARK(Hash64Int64); + +BENCHMARK(Hash64StructSmallStrings); +BENCHMARK(Hash64StructMediumStrings); +BENCHMARK(Hash64StructLargeStrings); + +BENCHMARK(Hash64Map); +BENCHMARK(Hash64ListInt64); +BENCHMARK(Hash64ListInt64HeavilySliced); +BENCHMARK(Hash64StringHeavilySliced); +BENCHMARK(Hash64StructWithNestedListHeavilySliced); + +} // namespace internal +} // namespace arrow diff --git a/cpp/src/arrow/compute/kernels/scalar_hash_test.cc b/cpp/src/arrow/compute/kernels/scalar_hash_test.cc new file mode 100644 index 00000000000..b7ef7d7460f --- /dev/null +++ b/cpp/src/arrow/compute/kernels/scalar_hash_test.cc @@ -0,0 +1,1089 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +#include +#include + +#include "arrow/array/builder_nested.h" +#include "arrow/array/builder_primitive.h" +#include "arrow/chunked_array.h" +#include "arrow/compute/api.h" +#include "arrow/compute/kernels/test_util_internal.h" +#include "arrow/compute/key_hash_internal.h" +#include "arrow/compute/util.h" +#include "arrow/result.h" +#include "arrow/status.h" +#include "arrow/testing/extension_type.h" +#include "arrow/testing/gtest_util.h" +#include "arrow/testing/matchers.h" +#include "arrow/testing/random.h" +#include "arrow/testing/util.h" +#include "arrow/util/bit_util.h" +#include "arrow/util/cpu_info.h" +#include "arrow/util/key_value_metadata.h" + +namespace arrow { +namespace compute { + +constexpr auto kSeed = 0x94378165; +constexpr auto kArrayLengths = {0, 50, 100}; +constexpr auto kNullProbabilities = {0.0, 0.5, 1.0}; + +class TestScalarHash : public ::testing::Test { + public: + template + void AssertHashesEqual(const std::shared_ptr& arr, Datum res, + std::vector exp) { + auto res_array = res.array(); + for (int64_t val_ndx = 0; val_ndx < arr->length(); ++val_ndx) { + c_type actual_hash = res_array->GetValues(1)[val_ndx]; + if (arr->IsNull(val_ndx)) { + ASSERT_EQ(0, actual_hash); + } else { + ASSERT_EQ(exp[val_ndx], actual_hash); + } + } + } + + template + std::vector HashPrimitive(const std::shared_ptr& arr) { + std::vector hashes(arr->length()); + // Choose the Hasher type conditionally based on c_type + + if constexpr (std::is_same_v) { + Hashing64::HashFixed(false, static_cast(arr->length()), + arr->type()->bit_width() / 8, + arr->data()->GetValues(1), hashes.data()); + } else { + Hashing32::HashFixed(::arrow::internal::CpuInfo::GetInstance()->hardware_flags(), + false, static_cast(arr->length()), + arr->type()->bit_width() / 8, + arr->data()->GetValues(1), hashes.data(), nullptr); + } + + // Matches scalar_hash.cc's remap: a valid row whose raw hash happens to be 0 would + // otherwise be indistinguishable from an actually-null row (also hashed to 0). + for (int64_t i = 0; i < arr->length(); i++) { + if (hashes[i] == 0 && arr->IsValid(i)) { + if constexpr (std::is_same_v) { + hashes[i] = Hashing64::CombineHashes(0, 0); + } else { + hashes[i] = Hashing32::CombineHashes(0, 0); + } + } + } + + return hashes; + } + + template + std::vector HashBinaryLike(const std::shared_ptr& arr) { + std::vector hashes(arr->length()); + auto length = static_cast(arr->length()); + auto values = arr->data()->GetValues(2); + if constexpr (std::is_same_v) { + if (arr->type_id() == Type::LARGE_BINARY || arr->type_id() == Type::LARGE_STRING) { + Hashing64::HashVarLen(false, length, arr->data()->GetValues(1), values, + hashes.data()); + } else { + Hashing64::HashVarLen(false, length, arr->data()->GetValues(1), values, + hashes.data()); + } + } else { + auto hw_flags = ::arrow::internal::CpuInfo::GetInstance()->hardware_flags(); + if (arr->type_id() == Type::LARGE_BINARY || arr->type_id() == Type::LARGE_STRING) { + Hashing32::HashVarLen(hw_flags, false, length, + arr->data()->GetValues(1), values, hashes.data(), + nullptr); + } else { + Hashing32::HashVarLen(hw_flags, false, length, + arr->data()->GetValues(1), values, hashes.data(), + nullptr); + } + } + return hashes; + } + + void CheckDeterministic(const std::string& func, const std::shared_ptr& arr) { + // Check that the hash is deterministic between different runs + ASSERT_OK_AND_ASSIGN(Datum res1, CallFunction(func, {arr})); + ASSERT_OK_AND_ASSIGN(Datum res2, CallFunction(func, {arr})); + ValidateOutput(res1); + ValidateOutput(res2); + ASSERT_EQ(res1.length(), arr->length()); + ASSERT_EQ(res2.length(), arr->length()); + if (func == "hash64") { + ASSERT_EQ(res1.type()->id(), Type::UINT64); + } else if (func == "hash32") { + ASSERT_EQ(res1.type()->id(), Type::UINT32); + } else { + FAIL() << "Unknown function: " << func; + } + AssertDatumsEqual(res1, res2); + + // Check that slicing the array does not affect the hash + auto hashes = res1.make_array(); + if (arr->length() >= 1) { + auto in1 = arr->Slice(1); + ASSERT_OK_AND_ASSIGN(Datum out1, CallFunction(func, {in1})); + ValidateOutput(out1); + AssertArraysEqual(*out1.make_array(), *hashes->Slice(1)); + } + if (arr->length() >= 4) { + auto in2 = arr->Slice(2, 2); + ASSERT_OK_AND_ASSIGN(Datum out2, CallFunction(func, {in2})); + ValidateOutput(out2); + AssertArraysEqual(*out2.make_array(), *hashes->Slice(2, 2)); + } + } + + void CheckHashQuality(const std::string& func, const std::shared_ptr& arr, + double tolerance = 1.0) { + ASSERT_OK_AND_ASSIGN(Datum result, CallFunction(func, {arr})); + auto hashes = result.make_array(); + + auto expected = arr->length(); + if (arr->null_count()) { + expected -= (arr->null_count() - 1); + } + if (func == "hash64") { + auto hashes64 = dynamic_cast(hashes.get()); + std::unordered_set hash_set; + for (int64_t i = 0; i < hashes64->length(); ++i) { + hash_set.insert(hashes64->Value(i)); + } + ASSERT_LE(hash_set.size(), expected); + ASSERT_GE(hash_set.size(), expected * tolerance); + } else if (func == "hash32") { + auto hashes32 = dynamic_cast(hashes.get()); + std::unordered_set hash_set; + for (int64_t i = 0; i < hashes32->length(); ++i) { + if (hashes32->IsValid(i)) { + hash_set.insert(hashes32->Value(i)); + } + } + ASSERT_LE(hash_set.size(), expected); + ASSERT_GE(hash_set.size(), expected * tolerance); + } else { + FAIL() << "Unknown function: " << func; + } + } + + void CheckPrimitive(const std::string& func, const std::shared_ptr& arr) { + ASSERT_OK_AND_ASSIGN(Datum hash_result, CallFunction(func, {arr})); + CheckDeterministic(func, arr); + if (func == "hash64") { + AssertHashesEqual(arr, hash_result, HashPrimitive(arr)); + } else if (func == "hash32") { + AssertHashesEqual(arr, hash_result, HashPrimitive(arr)); + } else { + FAIL() << "Unknown function: " << func; + } + } + + void CheckBinary(const std::string& func, const std::shared_ptr& arr) { + ASSERT_OK_AND_ASSIGN(Datum hash_result, CallFunction(func, {arr})); + CheckDeterministic(func, arr); + if (func == "hash64") { + AssertHashesEqual(arr, hash_result, HashBinaryLike(arr)); + } else if (func == "hash32") { + AssertHashesEqual(arr, hash_result, HashBinaryLike(arr)); + } else { + FAIL() << "Unknown function: " << func; + } + } +}; + +TEST_F(TestScalarHash, Null) { + Datum res; + std::shared_ptr arr; + std::shared_ptr exp; + + arr = ArrayFromJSON(null(), R"([])"); + exp = ArrayFromJSON(uint32(), "[]"); + ASSERT_OK_AND_ASSIGN(res, CallFunction("hash32", {arr})); + AssertArraysEqual(*res.make_array(), *exp); + CheckDeterministic("hash32", arr); + + arr = ArrayFromJSON(null(), R"([])"); + exp = ArrayFromJSON(uint64(), "[]"); + ASSERT_OK_AND_ASSIGN(res, CallFunction("hash64", {arr})); + AssertArraysEqual(*res.make_array(), *exp); + CheckDeterministic("hash64", arr); + + arr = ArrayFromJSON(null(), R"([null, null, null])"); + exp = ArrayFromJSON(uint32(), "[0, 0, 0]"); + ASSERT_OK_AND_ASSIGN(res, CallFunction("hash32", {arr})); + AssertArraysEqual(*res.make_array(), *exp); + CheckDeterministic("hash32", arr); + + arr = ArrayFromJSON(null(), R"([null, null, null])"); + exp = ArrayFromJSON(uint64(), "[0, 0, 0]"); + ASSERT_OK_AND_ASSIGN(res, CallFunction("hash64", {arr})); + AssertArraysEqual(*res.make_array(), *exp); + CheckDeterministic("hash64", arr); +} + +TEST_F(TestScalarHash, NullHashIsZero) { + auto arr1 = ArrayFromJSON(int32(), R"([null, 0, 1])"); + ASSERT_OK_AND_ASSIGN(auto res1, CallFunction("hash64", {arr1})); + auto buf1 = res1.array()->GetValues(1); + ASSERT_EQ(buf1[0], 0); + ASSERT_NE(buf1[1], 0); + ASSERT_NE(buf1[2], 0); + ASSERT_NE(buf1[1], buf1[2]); + + auto arr2 = ArrayFromJSON(int8(), R"([null, 0, 1])"); + ASSERT_OK_AND_ASSIGN(auto res2, CallFunction("hash32", {arr2})); + auto buf2 = res2.array()->GetValues(1); + ASSERT_EQ(buf2[0], 0); + ASSERT_NE(buf2[1], 0); + ASSERT_NE(buf2[2], 0); + ASSERT_NE(buf2[1], buf2[2]); +} + +// HashIntImp (used for any fixed-width type whose byte width is a power of 2 up to 8: +// ints, floats, dates, times, timestamps, durations) doesn't special-case an +// all-zero-bits key, so a legitimately valid "zero" value would otherwise hash to the +// same 0 scalar_hash.cc uses as the null sentinel. Checked across every affected byte +// width, not just int8/int32 (see NullHashIsZero). +TEST_F(TestScalarHash, ZeroValueDoesNotCollideWithNull) { + std::vector, std::string>> cases{ + {int8(), R"([null, 0, 1])"}, + {int16(), R"([null, 0, 1])"}, + {int32(), R"([null, 0, 1])"}, + {int64(), R"([null, 0, 1])"}, + {uint8(), R"([null, 0, 1])"}, + {uint16(), R"([null, 0, 1])"}, + {uint32(), R"([null, 0, 1])"}, + {uint64(), R"([null, 0, 1])"}, + {float32(), R"([null, 0.0, 1.0])"}, + {float64(), R"([null, 0.0, 1.0])"}, + {date32(), R"([null, 0, 1])"}, + {date64(), R"([null, 0, 86400000])"}, + {time32(TimeUnit::SECOND), R"([null, 0, 1])"}, + {time64(TimeUnit::NANO), R"([null, 0, 1])"}, + {timestamp(TimeUnit::SECOND), R"([null, 0, 1])"}, + {duration(TimeUnit::MILLI), R"([null, 0, 1])"}, + }; + for (const std::string func : {"hash32", "hash64"}) { + auto zero = func == "hash32" ? MakeScalar(uint32_t{0}) : MakeScalar(uint64_t{0}); + for (const auto& type_and_json : cases) { + auto arr = ArrayFromJSON(type_and_json.first, type_and_json.second); + ASSERT_OK_AND_ASSIGN(Datum result, CallFunction(func, {arr})); + auto hashes = result.make_array(); + ASSERT_OK_AND_ASSIGN(auto null_hash, hashes->GetScalar(0)); + ASSERT_OK_AND_ASSIGN(auto zero_hash, hashes->GetScalar(1)); + ASSERT_OK_AND_ASSIGN(auto one_hash, hashes->GetScalar(2)); + ASSERT_TRUE(null_hash->Equals(*zero)) << type_and_json.first->ToString(); + ASSERT_FALSE(zero_hash->Equals(*zero)) + << "valid zero-valued " << type_and_json.first->ToString() + << " should not collide with the null sentinel"; + ASSERT_FALSE(zero_hash->Equals(*one_hash)) << type_and_json.first->ToString(); + } + } +} + +TEST_F(TestScalarHash, Boolean) { + Datum result; + std::shared_ptr array; + auto input = ArrayFromJSON(boolean(), R"([true, false, null, true, null, false])"); + CheckDeterministic("hash32", input); + CheckDeterministic("hash64", input); + + ASSERT_OK_AND_ASSIGN(result, CallFunction("hash32", {input})); + + array = result.make_array(); + auto array32 = checked_cast(array.get()); + ASSERT_NE(array32->Value(0), array32->Value(1)); + ASSERT_NE(array32->Value(0), array32->Value(2)); + ASSERT_NE(array32->Value(1), array32->Value(2)); + ASSERT_EQ(array32->Value(0), array32->Value(3)); + ASSERT_EQ(array32->Value(2), array32->Value(4)); + ASSERT_EQ(array32->Value(1), array32->Value(5)); + + ASSERT_OK_AND_ASSIGN(result, CallFunction("hash64", {input})); + array = result.make_array(); + auto array64 = checked_cast(array.get()); + ASSERT_NE(array64->Value(0), array64->Value(1)); + ASSERT_NE(array64->Value(0), array64->Value(2)); + ASSERT_NE(array64->Value(1), array64->Value(2)); + ASSERT_EQ(array64->Value(0), array64->Value(3)); + ASSERT_EQ(array64->Value(2), array64->Value(4)); + ASSERT_EQ(array64->Value(1), array64->Value(5)); +} + +TEST_F(TestScalarHash, Primitive) { + auto types = {int8(), + int16(), + int32(), + int64(), + uint8(), + uint16(), + uint32(), + uint64(), + float16(), + float32(), + float64(), + time32(TimeUnit::SECOND), + time64(TimeUnit::NANO), + date32(), + date64(), + timestamp(TimeUnit::SECOND), + duration(TimeUnit::MILLI)}; + + for (auto func : {"hash32", "hash64"}) { + for (auto type : types) { + CheckPrimitive(func, ArrayFromJSON(type, R"([])")); + CheckPrimitive(func, ArrayFromJSON(type, R"([null])")); + CheckPrimitive(func, ArrayFromJSON(type, R"([1])")); + CheckPrimitive(func, ArrayFromJSON(type, R"([1, 2])")); + CheckPrimitive(func, ArrayFromJSON(type, R"([1, 2, null])")); + CheckPrimitive(func, ArrayFromJSON(type, R"([null, 2, 3])")); + CheckPrimitive(func, ArrayFromJSON(type, R"([1, 2, 3, 4])")); + } + } +} + +TEST_F(TestScalarHash, BinaryLike) { + auto types = {binary(), utf8(), large_binary(), large_utf8()}; + for (auto func : {"hash32", "hash64"}) { + for (auto type : types) { + CheckBinary(func, ArrayFromJSON(type, R"([])")); + CheckBinary(func, ArrayFromJSON(type, R"([null])")); + CheckBinary(func, ArrayFromJSON(type, R"([""])")); + CheckBinary(func, ArrayFromJSON(type, R"(["first", "second", null])")); + CheckBinary(func, ArrayFromJSON(type, R"(["first", "second", "third"])")); + CheckBinary(func, ArrayFromJSON(type, R"(["first", "second", "third"])")); + } + } + for (auto func : {"hash32", "hash64"}) { + auto type = fixed_size_binary(1); + CheckPrimitive(func, ArrayFromJSON(type, R"([])")); + CheckPrimitive(func, ArrayFromJSON(type, R"([null])")); + CheckPrimitive(func, ArrayFromJSON(type, R"(["a", "b"])")); + CheckPrimitive(func, ArrayFromJSON(type, R"([null, "b"])")); + + type = fixed_size_binary(3); + CheckPrimitive(func, ArrayFromJSON(type, R"([])")); + CheckPrimitive(func, ArrayFromJSON(type, R"([null])")); + CheckPrimitive(func, ArrayFromJSON(type, R"(["alt", "blt"])")); + CheckPrimitive(func, ArrayFromJSON(type, R"([null, "blt"])")); + } +} + +TEST_F(TestScalarHash, ExtensionType) { + auto storage = ArrayFromJSON(int16(), R"([1, 2, 3, 4, null])"); + auto extension = ExtensionType::WrapArray(smallint(), storage); + CheckPrimitive("hash32", extension); + CheckPrimitive("hash64", extension); +} + +TEST_F(TestScalarHash, DictionaryType) { + auto dict_type = dictionary(int8(), utf8()); + auto dict = DictArrayFromJSON(dict_type, "[1, 2, null, 3, 0]", + "[\"A0\", \"A1\", \"C2\", \"C3\"]"); + CheckPrimitive("hash32", dict); + CheckPrimitive("hash64", dict); +} + +TEST_F(TestScalarHash, RandomBinaryLike) { + auto rand = random::RandomArrayGenerator(kSeed); + auto types = {binary(), utf8(), large_binary(), large_utf8()}; + + for (auto length : kArrayLengths) { + for (auto null_probability : kNullProbabilities) { + for (auto type : types) { + auto arr = rand.ArrayOf(type, length, null_probability); + CheckBinary("hash32", arr); + CheckBinary("hash64", arr); + } + for (auto type : {fixed_size_binary(1), fixed_size_binary(3)}) { + auto arr = rand.ArrayOf(type, length, null_probability); + CheckPrimitive("hash32", arr); + CheckPrimitive("hash64", arr); + } + auto arr = rand.ArrayOf(fixed_size_binary(0), length, null_probability); + CheckDeterministic("hash32", arr); + CheckDeterministic("hash64", arr); + } + } +} + +TEST_F(TestScalarHash, RandomPrimitive) { + auto rand = random::RandomArrayGenerator(kSeed); + auto types = {int8(), + int16(), + int32(), + int64(), + uint8(), + uint16(), + uint32(), + uint64(), + float16(), + float32(), + float64(), + decimal128(18, 5), + decimal256(38, 5), + time32(TimeUnit::SECOND), + time64(TimeUnit::NANO), + date32(), + date64(), + timestamp(TimeUnit::SECOND), + duration(TimeUnit::MILLI)}; + + for (auto type : types) { + for (auto length : kArrayLengths) { + for (auto null_probability : kNullProbabilities) { + auto arr = rand.ArrayOf(type, length, null_probability); + CheckPrimitive("hash32", arr); + CheckPrimitive("hash64", arr); + if (type->bit_width() >= 16) { + // the generated arrays contain unique values at the given lengths + CheckHashQuality("hash32", arr, 0.98); + CheckHashQuality("hash64", arr, 0.98); + } + } + } + } +} + +TEST_F(TestScalarHash, RandomList) { + auto rand = random::RandomArrayGenerator(kSeed); + auto types = { + list(int32()), + list(float64()), + list(utf8()), + list(large_binary()), + large_list(int64()), + large_list(utf8()), + large_list(large_binary()), + list(boolean()), + list(list(int16())), + list(list(list(uint8()))), + fixed_size_list(int32(), 3), + }; + for (auto type : types) { + for (auto length : kArrayLengths) { + for (auto null_probability : kNullProbabilities) { + auto arr = rand.ArrayOf(type, length, null_probability); + CheckDeterministic("hash32", arr); + CheckDeterministic("hash64", arr); + } + } + } +} + +// GH-17211: hashing nested (list-like) child values reused the parent's element +// offsets directly as byte offsets into the hashed-child buffer, without +// rescaling by the width of the hashed code (4 bytes for hash32, 8 for hash64). +// This corrupted results in a way that depended on row position, so two +// occurrences of the exact same nested value at different rows would hash +// differently. +void CheckIdenticalRowsHashEqually(const std::string& func, + const std::shared_ptr& arr, int64_t row_a, + int64_t row_b) { + ASSERT_OK_AND_ASSIGN(Datum result, CallFunction(func, {arr})); + ASSERT_OK_AND_ASSIGN(auto scalar_a, result.make_array()->GetScalar(row_a)); + ASSERT_OK_AND_ASSIGN(auto scalar_b, result.make_array()->GetScalar(row_b)); + ASSERT_TRUE(scalar_a->Equals(*scalar_b)) + << "row " << row_a << " and row " << row_b << " have the same value in " + << arr->ToString() << " and should hash identically"; +} + +TEST_F(TestScalarHash, ListLikeDuplicateRowsHashEqually) { + for (const std::string func : {"hash32", "hash64"}) { + CheckIdenticalRowsHashEqually( + func, + ArrayFromJSON(fixed_size_list(int32(), 3), + "[[7, 8, 9], [100, 101, 102], [7, 8, 9], [200, 201, 202]]"), + 0, 2); + CheckIdenticalRowsHashEqually( + func, + ArrayFromJSON(list(int32()), + "[[7, 8, 9], [100, 101], [7, 8, 9], [200, 201, 202, 203]]"), + 0, 2); + CheckIdenticalRowsHashEqually( + func, + ArrayFromJSON(large_list(int32()), + "[[7, 8, 9], [100, 101], [7, 8, 9], [200, 201, 202, 203]]"), + 0, 2); + CheckIdenticalRowsHashEqually( + func, + ArrayFromJSON(list(list(int16())), + "[[[7, 8], [9]], [[1], [2, 3]], [[7, 8], [9]], [[4]]]"), + 0, 2); + CheckIdenticalRowsHashEqually( + func, + ArrayFromJSON( + map(utf8(), int32()), + R"([[["a", 1], ["b", 2]], [["c", 3]], [["a", 1], ["b", 2]], [["d", 4]]])"), + 0, 2); + CheckIdenticalRowsHashEqually( + func, + ArrayFromJSON( + struct_({field("f0", list(int32()))}), + R"([{"f0": [7, 8, 9]}, {"f0": [1, 2]}, {"f0": [7, 8, 9]}, {"f0": [4]}])"), + 0, 2); + } +} + +// Same as above, but with a large array and the duplicated rows far apart, as a +// stress test of the row-folding loop in HashArray's is_list_like branch beyond +// the handful of rows exercised above. +TEST_F(TestScalarHash, ListLikeDuplicateRowsFarApartHashEqually) { + constexpr int64_t kRowA = 10; + constexpr int64_t kRowB = 2 * util::MiniBatch::kMiniBatchLength + 10; + constexpr int64_t kLength = kRowB + 100; + + Int32Builder value_builder; + ListBuilder list_builder(default_memory_pool(), std::make_shared()); + auto* values = checked_cast(list_builder.value_builder()); + for (int64_t row = 0; row < kLength; row++) { + ASSERT_OK(list_builder.Append()); + int64_t content = row == kRowB ? kRowA : row; + ASSERT_OK(values->Append(static_cast(content))); + ASSERT_OK(values->Append(static_cast(content + 1))); + } + ASSERT_OK_AND_ASSIGN(auto arr, list_builder.Finish()); + + for (const std::string func : {"hash32", "hash64"}) { + CheckIdenticalRowsHashEqually(func, arr, kRowA, kRowB); + } +} + +// Guards against HashChild hashing the entire (unsliced) child values array instead +// of only the range referenced by this slice of the parent list/map array: since +// ArrayData::Slice() doesn't slice child_data, a small slice of a much larger list +// array must still hash identically to an equivalent, independently-built array. +TEST_F(TestScalarHash, ListLikeSliceOfLargerArrayMatchesIndependentArray) { + constexpr int64_t kTotalRows = 1000; + constexpr int64_t kSliceOffset = 137; + constexpr int64_t kSliceLength = 10; + + Int32Builder value_builder; + ListBuilder list_builder(default_memory_pool(), std::make_shared()); + auto* values = checked_cast(list_builder.value_builder()); + for (int64_t row = 0; row < kTotalRows; row++) { + ASSERT_OK(list_builder.Append()); + ASSERT_OK(values->Append(static_cast(row))); + ASSERT_OK(values->Append(static_cast(row + 1))); + } + ASSERT_OK_AND_ASSIGN(auto large_arr, list_builder.Finish()); + auto sliced = large_arr->Slice(kSliceOffset, kSliceLength); + + ListBuilder independent_builder(default_memory_pool(), + std::make_shared()); + auto* independent_values = + checked_cast(independent_builder.value_builder()); + for (int64_t row = kSliceOffset; row < kSliceOffset + kSliceLength; row++) { + ASSERT_OK(independent_builder.Append()); + ASSERT_OK(independent_values->Append(static_cast(row))); + ASSERT_OK(independent_values->Append(static_cast(row + 1))); + } + ASSERT_OK_AND_ASSIGN(auto independent_arr, independent_builder.Finish()); + + for (const std::string func : {"hash32", "hash64"}) { + ASSERT_OK_AND_ASSIGN(Datum sliced_result, CallFunction(func, {sliced})); + ASSERT_OK_AND_ASSIGN(Datum independent_result, CallFunction(func, {independent_arr})); + AssertDatumsEqual(sliced_result, independent_result); + } +} + +// Same as ListLikeSliceOfLargerArrayMatchesIndependentArray, but for FIXED_SIZE_LIST, +// which computes its referenced range via arithmetic (offset * list_size) rather than +// reading an offsets buffer, so it's a genuinely different code path worth covering +// on its own. +TEST_F(TestScalarHash, FixedSizeListSliceOfLargerArrayMatchesIndependentArray) { + constexpr int64_t kTotalRows = 1000; + constexpr int64_t kSliceOffset = 137; + constexpr int64_t kSliceLength = 10; + constexpr int32_t kListSize = 2; + + FixedSizeListBuilder list_builder(default_memory_pool(), + std::make_shared(), kListSize); + auto* values = checked_cast(list_builder.value_builder()); + for (int64_t row = 0; row < kTotalRows; row++) { + ASSERT_OK(list_builder.Append()); + ASSERT_OK(values->Append(static_cast(row))); + ASSERT_OK(values->Append(static_cast(row + 1))); + } + ASSERT_OK_AND_ASSIGN(auto large_arr, list_builder.Finish()); + auto sliced = large_arr->Slice(kSliceOffset, kSliceLength); + + FixedSizeListBuilder independent_builder(default_memory_pool(), + std::make_shared(), kListSize); + auto* independent_values = + checked_cast(independent_builder.value_builder()); + for (int64_t row = kSliceOffset; row < kSliceOffset + kSliceLength; row++) { + ASSERT_OK(independent_builder.Append()); + ASSERT_OK(independent_values->Append(static_cast(row))); + ASSERT_OK(independent_values->Append(static_cast(row + 1))); + } + ASSERT_OK_AND_ASSIGN(auto independent_arr, independent_builder.Finish()); + + for (const std::string func : {"hash32", "hash64"}) { + ASSERT_OK_AND_ASSIGN(Datum sliced_result, CallFunction(func, {sliced})); + ASSERT_OK_AND_ASSIGN(Datum independent_result, CallFunction(func, {independent_arr})); + AssertDatumsEqual(sliced_result, independent_result); + } +} + +void CheckRowsHashDifferently(const std::string& func, const std::shared_ptr& arr, + int64_t row_a, int64_t row_b) { + ASSERT_OK_AND_ASSIGN(Datum result, CallFunction(func, {arr})); + ASSERT_OK_AND_ASSIGN(auto scalar_a, result.make_array()->GetScalar(row_a)); + ASSERT_OK_AND_ASSIGN(auto scalar_b, result.make_array()->GetScalar(row_b)); + ASSERT_FALSE(scalar_a->Equals(*scalar_b)) + << "row " << row_a << " and row " << row_b << " have different values in " + << arr->ToString() << " and should (in practice) hash differently"; +} + +// Guards against a degenerate fold (e.g. one that ignores element order, or only +// looks at the first/last element) that would satisfy the "identical content hashes +// identically" tests above while still being a broken hash function. +TEST_F(TestScalarHash, ListLikeDistinctContentHashesDifferently) { + for (const std::string func : {"hash32", "hash64"}) { + // Reordering elements should (in practice) change the hash. + CheckRowsHashDifferently(func, ArrayFromJSON(list(int32()), "[[1, 2, 3], [3, 2, 1]]"), + 0, 1); + // Changing one element's value should (in practice) change the hash. + CheckRowsHashDifferently(func, ArrayFromJSON(list(int32()), "[[1, 2, 3], [1, 2, 4]]"), + 0, 1); + // A shorter list shouldn't be a prefix-consistent truncation of a longer one. + CheckRowsHashDifferently(func, ArrayFromJSON(list(int32()), "[[1, 2], [1, 2, 3]]"), 0, + 1); + // Swapping map values between keys should (in practice) change the hash. + CheckRowsHashDifferently( + func, + ArrayFromJSON(map(utf8(), int32()), + R"([[["a", 1], ["b", 2]], [["a", 2], ["b", 1]]])"), + 0, 1); + } +} + +// The seed used to fold a list-like row's child hashes together (see +// FastHashScalar::CombineRange) is deliberately not 0, so that an empty (but +// non-null) list doesn't collide with a null list, which hashes to 0 (see +// NullHashIsZero). +TEST_F(TestScalarHash, ListLikeEmptyDiffersFromNull) { + for (const std::string func : {"hash32", "hash64"}) { + for (auto arr : { + ArrayFromJSON(list(int32()), "[[], null]"), + ArrayFromJSON(large_list(int32()), "[[], null]"), + ArrayFromJSON(map(utf8(), int32()), "[[], null]"), + }) { + ASSERT_OK_AND_ASSIGN(Datum result, CallFunction(func, {arr})); + auto hashes = result.make_array(); + ASSERT_OK_AND_ASSIGN(auto empty_hash, hashes->GetScalar(0)); + ASSERT_OK_AND_ASSIGN(auto null_hash, hashes->GetScalar(1)); + ASSERT_FALSE(empty_hash->Equals(*null_hash)) + << "hash of an empty " << arr->type()->ToString() + << " should not collide with hash of a null one"; + } + } +} + +// Mirrors NullHashIsZero, but for list-like types, whose null handling is a +// dedicated masking pass in HashArray's is_list_like branch rather than the +// generic path the other types go through. +TEST_F(TestScalarHash, ListLikeNullHashIsZero) { + for (const std::string func : {"hash32", "hash64"}) { + for (auto arr : { + ArrayFromJSON(fixed_size_list(int32(), 2), "[null, [1, 2]]"), + ArrayFromJSON(list(int32()), "[null, [1, 2]]"), + ArrayFromJSON(large_list(int32()), "[null, [1, 2]]"), + ArrayFromJSON(map(utf8(), int32()), R"([null, [["a", 1]]])"), + }) { + ASSERT_OK_AND_ASSIGN(Datum result, CallFunction(func, {arr})); + auto hashes = result.make_array(); + ASSERT_OK_AND_ASSIGN(auto null_hash, hashes->GetScalar(0)); + ASSERT_OK_AND_ASSIGN(auto non_null_hash, hashes->GetScalar(1)); + auto zero = func == "hash32" ? MakeScalar(uint32_t{0}) : MakeScalar(uint64_t{0}); + ASSERT_TRUE(null_hash->Equals(*zero)) + << "null " << arr->type()->ToString() << " should hash to 0"; + ASSERT_FALSE(non_null_hash->Equals(*zero)) + << "non-null " << arr->type()->ToString() << " should not hash to 0"; + } + } +} + +// Per the columnar format spec, a null slot may have a positive slot length over +// undefined memory. Build a LIST array where the null row's offsets span 3 real +// (non-garbage, but logically "don't care") values instead of the canonical empty +// range, to make sure CombineRange's output for that row is still discarded by the +// masking pass rather than leaking into the result. +TEST_F(TestScalarHash, ListNullWithNonEmptyOffsetRangeHashesToZero) { + auto offsets = ArrayFromJSON(int32(), "[0, 2, 5, 6]"); + auto values = ArrayFromJSON(int32(), "[10, 20, 30, 40, 50, 60]"); + ASSERT_OK_AND_ASSIGN(auto validity, AllocateEmptyBitmap(3)); + bit_util::SetBit(validity->mutable_data(), 0); + // Row 1 is null but its offset range [2, 5) is non-empty. + bit_util::SetBit(validity->mutable_data(), 2); + ASSERT_OK_AND_ASSIGN( + auto arr, ListArray::FromArrays(*offsets, *values, default_memory_pool(), validity, + /*null_count=*/1)); + ASSERT_TRUE(arr->IsNull(1)); + + for (const std::string func : {"hash32", "hash64"}) { + ASSERT_OK_AND_ASSIGN(Datum result, CallFunction(func, {arr})); + auto hashes = result.make_array(); + ASSERT_OK_AND_ASSIGN(auto null_hash, hashes->GetScalar(1)); + auto zero = func == "hash32" ? MakeScalar(uint32_t{0}) : MakeScalar(uint64_t{0}); + ASSERT_TRUE(null_hash->Equals(*zero)) + << "null row with a non-empty offset range should still hash to 0"; + } +} + +// The generic path (bool, int, string, ...) zeroes nulls via HashMultiColumn, while +// list-like types are zeroed by HashArray's own is_list_like branch (see +// ListLikeNullHashIsZero) and struct by recursing into per-field columns fed back +// into HashMultiColumn. Check they all agree on the same sentinel (0), not just each +// individually hashing null to *something* self-consistent. +TEST_F(TestScalarHash, NullHashIsZeroAcrossTypes) { + for (const std::string func : {"hash32", "hash64"}) { + auto zero = func == "hash32" ? MakeScalar(uint32_t{0}) : MakeScalar(uint64_t{0}); + for (auto arr : { + ArrayFromJSON(boolean(), "[null]"), + ArrayFromJSON(int32(), "[null]"), + ArrayFromJSON(utf8(), "[null]"), + ArrayFromJSON(list(int32()), "[null]"), + ArrayFromJSON(struct_({field("f0", int32())}), "[null]"), + ArrayFromJSON(map(utf8(), int32()), "[null]"), + }) { + ASSERT_OK_AND_ASSIGN(Datum result, CallFunction(func, {arr})); + ASSERT_OK_AND_ASSIGN(auto null_hash, result.make_array()->GetScalar(0)); + ASSERT_TRUE(null_hash->Equals(*zero)) + << "null " << arr->type()->ToString() << " should hash to the same 0 " + << "sentinel as every other type"; + } + } +} + +// GH-17211: a nested (list-like or struct) field that is independently null within +// an otherwise-valid struct row must still hash as null (0), same as a plain field. +// HashChild used to attach the *parent* struct's validity to the child hash buffer +// instead of the field's own, so an independently-null nested field's already-zeroed +// hash data got re-hashed via HashFixed as if it were ordinary (non-null) data, +// silently producing a non-zero result instead of the documented 0 sentinel. +TEST_F(TestScalarHash, NestedNullFieldWithinValidStructHashesToZero) { + for (const std::string func : {"hash32", "hash64"}) { + auto zero = func == "hash32" ? MakeScalar(uint32_t{0}) : MakeScalar(uint64_t{0}); + + // Plain (non-nested) null field, for comparison: already correct beforehand. + auto plain = ArrayFromJSON(struct_({field("f0", int32())}), R"([{"f0": null}])"); + ASSERT_OK_AND_ASSIGN(Datum plain_result, CallFunction(func, {plain})); + ASSERT_OK_AND_ASSIGN(auto plain_hash, plain_result.make_array()->GetScalar(0)); + ASSERT_TRUE(plain_hash->Equals(*zero)); + + for (auto nested : { + ArrayFromJSON(struct_({field("f0", list(int32()))}), R"([{"f0": null}])"), + ArrayFromJSON(struct_({field("f0", struct_({field("g0", int32())}))}), + R"([{"f0": null}])"), + }) { + ASSERT_OK_AND_ASSIGN(Datum nested_result, CallFunction(func, {nested})); + ASSERT_OK_AND_ASSIGN(auto nested_hash, nested_result.make_array()->GetScalar(0)); + ASSERT_TRUE(nested_hash->Equals(*zero)) + << "independently-null " << nested->type()->ToString() + << " field should hash to 0, same as a plain null field"; + } + } +} + +// Guards against HashChild reusing a nested field's raw (unshifted) validity buffer +// without rebasing it: the buffer requires bit `child.offset + i` to read logical row +// i, but the returned ArrayData has offset 0 and its buffer is read directly (bit 0 = +// row 0) once wrapped in a KeyColumnArray. If a struct's nested field is itself an +// offset slice of a larger array (e.g. GH-17211), this misreads validity by +// `child.offset` bits -- here, a valid row would be misread as null (or vice versa) +// unless the buffer is rebased to be self-consistent with the fresh hash values. +TEST_F(TestScalarHash, NestedFieldWithOwnOffsetHashesCorrectly) { + ListBuilder list_builder(default_memory_pool(), std::make_shared()); + auto* values = checked_cast(list_builder.value_builder()); + ASSERT_OK(list_builder.AppendNull()); + for (int32_t row = 1; row < 10; row++) { + ASSERT_OK(list_builder.Append()); + ASSERT_OK(values->Append(row)); + ASSERT_OK(values->Append(row + 1)); + } + ASSERT_OK_AND_ASSIGN(auto base, list_builder.Finish()); + auto sliced_field = base->Slice(3, 5); // offset=3, length=5; logical row 0 = valid + + ASSERT_OK_AND_ASSIGN(auto struct_with_offset_field, + StructArray::Make({sliced_field}, {field("f0", list(int32()))})); + + ListBuilder independent_builder(default_memory_pool(), + std::make_shared()); + auto* independent_values = + checked_cast(independent_builder.value_builder()); + for (int32_t row = 3; row < 8; row++) { + ASSERT_OK(independent_builder.Append()); + ASSERT_OK(independent_values->Append(row)); + ASSERT_OK(independent_values->Append(row + 1)); + } + ASSERT_OK_AND_ASSIGN(auto independent_field, independent_builder.Finish()); + ASSERT_OK_AND_ASSIGN( + auto independent_struct, + StructArray::Make({independent_field}, {field("f0", list(int32()))})); + + for (const std::string func : {"hash32", "hash64"}) { + ASSERT_OK_AND_ASSIGN(Datum offset_result, + CallFunction(func, {struct_with_offset_field})); + ASSERT_OK_AND_ASSIGN(Datum independent_result, + CallFunction(func, {independent_struct})); + AssertDatumsEqual(offset_result, independent_result); + } +} + +// Same idea as ListLikeSliceOfLargerArrayMatchesIndependentArray, but for a nested +// field within a struct: StructArray::Slice() also doesn't reslice child_data, so a +// small slice of a struct with a large nested list field must still hash identically +// to an equivalent, independently-built struct. +TEST_F(TestScalarHash, StructWithNestedFieldSliceOfLargerArrayMatchesIndependentArray) { + constexpr int64_t kTotalRows = 1000; + constexpr int64_t kSliceOffset = 137; + constexpr int64_t kSliceLength = 10; + + ListBuilder list_builder(default_memory_pool(), std::make_shared()); + auto* values = checked_cast(list_builder.value_builder()); + for (int64_t row = 0; row < kTotalRows; row++) { + ASSERT_OK(list_builder.Append()); + ASSERT_OK(values->Append(static_cast(row))); + ASSERT_OK(values->Append(static_cast(row + 1))); + } + ASSERT_OK_AND_ASSIGN(auto large_list, list_builder.Finish()); + ASSERT_OK_AND_ASSIGN(auto large_struct, + StructArray::Make({large_list}, {field("f0", list(int32()))})); + auto sliced = large_struct->Slice(kSliceOffset, kSliceLength); + + ListBuilder independent_builder(default_memory_pool(), + std::make_shared()); + auto* independent_values = + checked_cast(independent_builder.value_builder()); + for (int64_t row = kSliceOffset; row < kSliceOffset + kSliceLength; row++) { + ASSERT_OK(independent_builder.Append()); + ASSERT_OK(independent_values->Append(static_cast(row))); + ASSERT_OK(independent_values->Append(static_cast(row + 1))); + } + ASSERT_OK_AND_ASSIGN(auto independent_list, independent_builder.Finish()); + ASSERT_OK_AND_ASSIGN( + auto independent_struct, + StructArray::Make({independent_list}, {field("f0", list(int32()))})); + + for (const std::string func : {"hash32", "hash64"}) { + ASSERT_OK_AND_ASSIGN(Datum sliced_result, CallFunction(func, {sliced})); + ASSERT_OK_AND_ASSIGN(Datum independent_result, + CallFunction(func, {independent_struct})); + AssertDatumsEqual(sliced_result, independent_result); + } +} + +// The EXTENSION unwrapping at the top of HashArray should compose with the +// is_list_like recursion; this combination was otherwise untested (ExtensionType +// above only wraps a primitive). +TEST_F(TestScalarHash, ExtensionTypeWrappingList) { + auto storage = ArrayFromJSON(list(int32()), "[[7, 8, 9], [1, 2], [7, 8, 9]]"); + auto extension = ExtensionType::WrapArray(list_extension_type(), storage); + CheckIdenticalRowsHashEqually("hash32", extension, 0, 2); + CheckIdenticalRowsHashEqually("hash64", extension, 0, 2); +} + +TEST_F(TestScalarHash, RandomStruct) { + auto rand = random::RandomArrayGenerator(kSeed); + auto types = { + struct_({field("f0", int32())}), + struct_({field("f0", int32()), field("f1", utf8())}), + struct_({field("f0", list(int32()))}), + struct_({field("f0", struct_({field("f0", int32()), field("f1", utf8())}))}), + }; + for (auto type : types) { + for (auto length : kArrayLengths) { + for (auto null_probability : kNullProbabilities) { + auto arr = rand.ArrayOf(type, length, null_probability); + CheckDeterministic("hash32", arr); + CheckDeterministic("hash64", arr); + } + } + } +} + +// Guards against a struct field's own pre-existing offset (independent of the struct +// array's own offset) being silently ignored. StructArray::Slice() only touches the +// struct's own top-level offset -- child fields are not resliced (see +// StructArray::GetFlattenedField, which composes the struct's offset with each child's +// own offset) -- so a struct built from an already-offset field (e.g. a slice of a +// larger array) must still hash identically to an equivalent, independently-built +// struct with a zero-offset field. +TEST_F(TestScalarHash, StructFieldWithOwnOffsetHashesCorrectly) { + Int32Builder base_builder; + for (int32_t v = 0; v < 10; v++) { + ASSERT_OK(base_builder.Append(v)); + } + ASSERT_OK_AND_ASSIGN(auto base, base_builder.Finish()); + auto sliced_field = base->Slice(3, 4); // offset=3, length=4, content [3, 4, 5, 6] + ASSERT_GT(sliced_field->offset(), 0); + + Int32Builder second_builder; + for (int32_t v : {100, 101, 102, 103}) { + ASSERT_OK(second_builder.Append(v)); + } + ASSERT_OK_AND_ASSIGN(auto second_field, second_builder.Finish()); + + ASSERT_OK_AND_ASSIGN(auto struct_with_offset_field, + StructArray::Make({sliced_field, second_field}, + {field("f0", int32()), field("f1", int32())})); + + Int32Builder independent_builder; + for (int32_t v : {3, 4, 5, 6}) { + ASSERT_OK(independent_builder.Append(v)); + } + ASSERT_OK_AND_ASSIGN(auto independent_field, independent_builder.Finish()); + ASSERT_OK_AND_ASSIGN(auto independent_struct, + StructArray::Make({independent_field, second_field}, + {field("f0", int32()), field("f1", int32())})); + + for (const std::string func : {"hash32", "hash64"}) { + ASSERT_OK_AND_ASSIGN(Datum offset_result, + CallFunction(func, {struct_with_offset_field})); + ASSERT_OK_AND_ASSIGN(Datum independent_result, + CallFunction(func, {independent_struct})); + AssertDatumsEqual(offset_result, independent_result); + } +} + +// Guards against a crash on a zero-field struct: HashMultiColumn requires at least +// one column (it reads cols[0] unconditionally), so this type needs its own path in +// HashStructArray rather than falling through to HashMultiColumn with an empty list. +TEST_F(TestScalarHash, EmptyFieldStructHashesWithoutCrashing) { + auto type = struct_({}); + ASSERT_OK_AND_ASSIGN(auto validity, AllocateEmptyBitmap(2)); + bit_util::SetBit(validity->mutable_data(), 0); // row 0 valid, row 1 null + auto array_data = ArrayData::Make(type, 2, {validity}, /*null_count=*/1); + auto arr = MakeArray(array_data); + ASSERT_TRUE(arr->IsValid(0)); + ASSERT_TRUE(arr->IsNull(1)); + + for (const std::string func : {"hash32", "hash64"}) { + CheckDeterministic(func, arr); + ASSERT_OK_AND_ASSIGN(Datum result, CallFunction(func, {arr})); + auto hashes = result.make_array(); + ASSERT_OK_AND_ASSIGN(auto valid_hash, hashes->GetScalar(0)); + ASSERT_OK_AND_ASSIGN(auto null_hash, hashes->GetScalar(1)); + auto zero = func == "hash32" ? MakeScalar(uint32_t{0}) : MakeScalar(uint64_t{0}); + ASSERT_FALSE(valid_hash->Equals(*zero)); + ASSERT_TRUE(null_hash->Equals(*zero)); + } +} + +TEST_F(TestScalarHash, RandomMap) { + auto rand = random::RandomArrayGenerator(kSeed); + auto types = { + map(int32(), int32()), + map(int32(), utf8()), + map(utf8(), list(int16())), + map(utf8(), map(int32(), int32())), + }; + for (auto type : types) { + for (auto length : kArrayLengths) { + for (auto null_probability : kNullProbabilities) { + auto arr = rand.ArrayOf(type, length, null_probability); + CheckDeterministic("hash32", arr); + CheckDeterministic("hash64", arr); + } + } + } +} + +TEST_F(TestScalarHash, UnsupportedTypes) { + auto rand = random::RandomArrayGenerator(kSeed); + auto types = {list_view(int64()), + large_list_view(int64()), + binary_view(), + utf8_view(), + dense_union({field("a", int64()), field("b", binary())}), + sparse_union({field("a", int64()), field("b", binary())}), + run_end_encoded(int16(), utf8())}; + for (auto type : types) { + auto arr = rand.ArrayOf(type, 1, 0); + ASSERT_RAISES(NotImplemented, CallFunction("hash32", {arr})); + ASSERT_RAISES(NotImplemented, CallFunction("hash64", {arr})); + } +} + +// HashableMatcher only saw the top-level EXTENSION type id, so an extension wrapping +// an unsupported storage type (e.g. binary_view) passed dispatch and only failed +// later with a raw TypeError from ToColumnArray instead of a clean NotImplemented. +TEST_F(TestScalarHash, UnsupportedExtensionStorageType) { + auto storage = ArrayFromJSON(binary_view(), R"(["a", "b"])"); + auto extension = ExtensionType::WrapArray(binary_view_extension_type(), storage); + ASSERT_RAISES(NotImplemented, CallFunction("hash32", {extension})); + ASSERT_RAISES(NotImplemented, CallFunction("hash64", {extension})); +} + +// copied from cpp/src/arrow/util/hashing_test.cc +template +static std::unordered_set MakeSequentialIntegers(int32_t n_values) { + std::unordered_set values; + values.reserve(n_values); + + for (int32_t i = 0; i < n_values; ++i) { + values.insert(static_cast(i)); + } + ARROW_DCHECK_EQ(values.size(), static_cast(n_values)); + return values; +} + +// copied from cpp/src/arrow/util/hashing_test.cc +static std::unordered_set MakeDistinctStrings(int32_t n_values) { + std::unordered_set values; + values.reserve(n_values); + + // Generate strings between 0 and 24 bytes, with ASCII characters + std::default_random_engine gen(42); + std::uniform_int_distribution length_dist(0, 24); + std::uniform_int_distribution char_dist('0', 'z'); + + while (values.size() < static_cast(n_values)) { + auto length = length_dist(gen); + std::string s(length, 'X'); + for (int32_t i = 0; i < length; ++i) { + s[i] = static_cast(char_dist(gen)); + } + values.insert(std::move(s)); + } + return values; +} + +TEST_F(TestScalarHash, HashQuality) { + for (auto& func : {"hash32", "hash64"}) { + std::shared_ptr arr; + auto integer_values = MakeSequentialIntegers(100000); + auto integer_vector = + std::vector(integer_values.begin(), integer_values.end()); + arrow::ArrayFromVector(integer_vector, &arr); + CheckHashQuality(func, arr); + + auto string_values = MakeDistinctStrings(10000); + auto string_vector = + std::vector(string_values.begin(), string_values.end()); + arrow::ArrayFromVector(string_vector, &arr); + CheckHashQuality(func, arr); + } +} + +} // namespace compute +} // namespace arrow diff --git a/cpp/src/arrow/compute/key_hash_benchmark.cc b/cpp/src/arrow/compute/key_hash_benchmark.cc new file mode 100644 index 00000000000..31b18b79c9a --- /dev/null +++ b/cpp/src/arrow/compute/key_hash_benchmark.cc @@ -0,0 +1,119 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +#include +#include +#include +#include +#include +#include + +#include "benchmark/benchmark.h" + +#include "arrow/testing/gtest_util.h" +#include "arrow/testing/random.h" +#include "arrow/util/hashing.h" + +#include "arrow/array/builder_primitive.h" +#include "arrow/compute/key_hash_internal.h" +#include "arrow/compute/util_internal.h" + +namespace arrow { +namespace internal { + +namespace { +// copied from scalar_string_benchmark +constexpr auto kSeed = 0x94378165; + +static random::RandomArrayGenerator hashing_rng(kSeed); +} // namespace + +static void KeyHashIntegers32(benchmark::State& state) { // NOLINT non-const reference + auto test_vals = hashing_rng.Int32(10000, 0, std::numeric_limits::max()); + + // initialize the stack allocator + util::TempVectorStack stack_memallocator; + ASSERT_OK(stack_memallocator.Init(compute::default_exec_context()->memory_pool(), + compute::Hashing32::kHashBatchTempStackUsage)); + + // prepare the execution context for Hashing32 + compute::LightContext hash_ctx; + hash_ctx.hardware_flags = compute::default_exec_context()->cpu_info()->hardware_flags(); + hash_ctx.stack = &stack_memallocator; + + // allocate memory for results + ASSERT_OK_AND_ASSIGN(std::unique_ptr hash_buffer, + AllocateBuffer(test_vals->length() * sizeof(int32_t))); + + // Prepare input data structure for propagation to hash function + ASSERT_OK_AND_ASSIGN( + compute::KeyColumnArray input_keycol, + compute::ColumnArrayFromArrayData(test_vals->data(), 0, test_vals->length())); + std::vector columns{input_keycol}; + + // run the benchmark + while (state.KeepRunning()) { + compute::Hashing32::HashMultiColumn( + columns, &hash_ctx, reinterpret_cast(hash_buffer->mutable_data())); + } + + state.SetBytesProcessed(state.iterations() * test_vals->length() * sizeof(int32_t)); + state.SetItemsProcessed(state.iterations() * test_vals->length()); +} + +static void KeyHashIntegers64(benchmark::State& state) { // NOLINT non-const reference + auto test_vals = hashing_rng.Int64(10000, 0, std::numeric_limits::max()); + + // initialize the stack allocator + util::TempVectorStack stack_memallocator; + ASSERT_OK(stack_memallocator.Init(compute::default_exec_context()->memory_pool(), + compute::Hashing64::kHashBatchTempStackUsage)); + + // prepare the execution context for Hashing64 + compute::LightContext hash_ctx; + hash_ctx.hardware_flags = compute::default_exec_context()->cpu_info()->hardware_flags(); + hash_ctx.stack = &stack_memallocator; + + // allocate memory for results + ASSERT_OK_AND_ASSIGN(std::unique_ptr hash_buffer, + AllocateBuffer(test_vals->length() * sizeof(int64_t))); + + // Prepare input data structure for propagation to hash function + ASSERT_OK_AND_ASSIGN( + compute::KeyColumnArray input_keycol, + compute::ColumnArrayFromArrayData(test_vals->data(), 0, test_vals->length())); + std::vector columns{input_keycol}; + + // run the benchmark + while (state.KeepRunning()) { + compute::Hashing64::HashMultiColumn( + columns, &hash_ctx, reinterpret_cast(hash_buffer->mutable_data())); + } + + state.SetBytesProcessed(state.iterations() * test_vals->length() * sizeof(int64_t)); + state.SetItemsProcessed(state.iterations() * test_vals->length()); +} + +// ---------------------------------------------------------------------- +// Benchmark declarations + +// Directly uses "KeyHash" hash functions from key_hash.h (xxHash-like) +BENCHMARK(KeyHashIntegers32); +BENCHMARK(KeyHashIntegers64); + +} // namespace internal +} // namespace arrow diff --git a/cpp/src/arrow/compute/key_hash_internal.h b/cpp/src/arrow/compute/key_hash_internal.h index d141603ce0f..54b709f1989 100644 --- a/cpp/src/arrow/compute/key_hash_internal.h +++ b/cpp/src/arrow/compute/key_hash_internal.h @@ -37,6 +37,7 @@ enum class BloomFilterBuildStrategy; // class ARROW_COMPUTE_EXPORT Hashing32 { friend class TestVectorHash; + friend class TestScalarHash; template friend void TestBloomLargeHashHelper(int64_t, int64_t, const std::vector&, int64_t, int, T*); @@ -46,6 +47,13 @@ class ARROW_COMPUTE_EXPORT Hashing32 { static void HashMultiColumn(const std::vector& cols, LightContext* ctx, uint32_t* out_hash); + // Combine two hash values into one, e.g. to fold together the hashes of a nested + // column's child elements, or of a row's separate key columns (as HashMultiColumn + // does internally). + static uint32_t CombineHashes(uint32_t previous_hash, uint32_t hash) { + return CombineHashesImp(previous_hash, hash); + } + // Clarify the max temp stack usage for HashBatch, which might be necessary for the // caller to be aware of at compile time to reserve enough stack size in advance. The // HashBatch implementation uses one uint32 temp vector as a buffer for hash, one uint16 @@ -160,6 +168,7 @@ class ARROW_COMPUTE_EXPORT Hashing32 { class ARROW_COMPUTE_EXPORT Hashing64 { friend class TestVectorHash; + friend class TestScalarHash; template friend void TestBloomLargeHashHelper(int64_t, int64_t, const std::vector&, int64_t, int, T*); @@ -169,6 +178,13 @@ class ARROW_COMPUTE_EXPORT Hashing64 { static void HashMultiColumn(const std::vector& cols, LightContext* ctx, uint64_t* hashes); + // Combine two hash values into one, e.g. to fold together the hashes of a nested + // column's child elements, or of a row's separate key columns (as HashMultiColumn + // does internally). + static uint64_t CombineHashes(uint64_t previous_hash, uint64_t hash) { + return CombineHashesImp(previous_hash, hash); + } + // Clarify the max temp stack usage for HashBatch, which might be necessary for the // caller to be aware of at compile time to reserve enough stack size in advance. The // HashBatch implementation uses one uint16 temp vector as a buffer for null indices and diff --git a/cpp/src/arrow/compute/registry_internal.h b/cpp/src/arrow/compute/registry_internal.h index 5b9d7f8d608..90165abba69 100644 --- a/cpp/src/arrow/compute/registry_internal.h +++ b/cpp/src/arrow/compute/registry_internal.h @@ -30,6 +30,7 @@ void RegisterScalarBoolean(FunctionRegistry* registry); void RegisterScalarCast(FunctionRegistry* registry); void RegisterDictionaryDecode(FunctionRegistry* registry); void RegisterScalarComparison(FunctionRegistry* registry); +void RegisterScalarHash(FunctionRegistry* registry); void RegisterScalarIfElse(FunctionRegistry* registry); void RegisterScalarNested(FunctionRegistry* registry); void RegisterScalarRandom(FunctionRegistry* registry); // Nullary diff --git a/cpp/src/arrow/util/hashing_benchmark.cc b/cpp/src/arrow/util/hashing_benchmark.cc index c7051d1a351..4d322cc1ecb 100644 --- a/cpp/src/arrow/util/hashing_benchmark.cc +++ b/cpp/src/arrow/util/hashing_benchmark.cc @@ -24,6 +24,7 @@ #include "benchmark/benchmark.h" +#include "arrow/array/builder_primitive.h" #include "arrow/testing/gtest_util.h" #include "arrow/util/hashing.h" @@ -62,7 +63,22 @@ static std::vector MakeStrings(int32_t n_values, int32_t min_length return values; } -static void HashIntegers(benchmark::State& state) { // NOLINT non-const reference +static void HashIntegers32(benchmark::State& state) { // NOLINT non-const reference + const std::vector values = MakeIntegers(10000); + + while (state.KeepRunning()) { + hash_t total = 0; + for (const int32_t v : values) { + total += ScalarHelper::ComputeHash(v); + total += ScalarHelper::ComputeHash(v); + } + benchmark::DoNotOptimize(total); + } + state.SetBytesProcessed(2 * state.iterations() * values.size() * sizeof(int32_t)); + state.SetItemsProcessed(2 * state.iterations() * values.size()); +} + +static void HashIntegers64(benchmark::State& state) { // NOLINT non-const reference const std::vector values = MakeIntegers(10000); while (state.KeepRunning()) { @@ -114,7 +130,9 @@ static void HashLargeStrings(benchmark::State& state) { // NOLINT non-const ref // ---------------------------------------------------------------------- // Benchmark declarations -BENCHMARK(HashIntegers); +// Directly uses "Hashing" hash functions from hashing.h (xxHash) +BENCHMARK(HashIntegers32); +BENCHMARK(HashIntegers64); BENCHMARK(HashSmallStrings); BENCHMARK(HashMediumStrings); BENCHMARK(HashLargeStrings); diff --git a/docs/source/cpp/compute.rst b/docs/source/cpp/compute.rst index 1e067c52188..5a4d18672fa 100644 --- a/docs/source/cpp/compute.rst +++ b/docs/source/cpp/compute.rst @@ -1282,6 +1282,25 @@ Containment tests * \(8) Output is true iff :member:`MatchSubstringOptions::pattern` matches the corresponding input element at any position. +Hash Functions +~~~~~~~~~~~~~~ + +Not to be confused with the "group by" functions, Hash functions produce an array of hash +values corresponding to the length of the input. Currently, these functions take a single +array as input. + ++---------------+-------+-------------+-------------+---------------+-------+ +| Function name | Arity | Input types | Output type | Options class | Notes | ++===============+=======+=============+=============+===============+=======+ +| hash32 | Unary | Any | UInt32 | | \(1) | ++---------------+-------+-------------+-------------+---------------+-------+ +| hash64 | Unary | Any | UInt64 | | \(1) | ++---------------+-------+-------------+-------------+---------------+-------+ + +* \(1) The implementation doesn't guarantee hash stability across different versions of + the library. Union, view and run end encoded types are not supported yet. Null + values do not hash to null; they hash to a fixed sentinel value. + Categorizations ~~~~~~~~~~~~~~~ diff --git a/python/pyarrow/tests/strategies.py b/python/pyarrow/tests/strategies.py index cb96f71e262..65395a5a801 100644 --- a/python/pyarrow/tests/strategies.py +++ b/python/pyarrow/tests/strategies.py @@ -213,18 +213,22 @@ def fields(draw, type_strategy=primitive_types, name_strategy=None): return pa.field(name, type=typ, nullable=nullable, metadata=meta) -def list_types(item_strategy=primitive_types): - return ( +def list_types(item_strategy=primitive_types, include_views=True): + types = ( st.builds(pa.list_, item_strategy) | st.builds(pa.large_list, item_strategy) | st.builds( pa.list_, item_strategy, st.integers(min_value=0, max_value=16) - ) | - st.builds(pa.list_view, item_strategy) | - st.builds(pa.large_list_view, item_strategy) + ) ) + if include_views: + types |= ( + st.builds(pa.list_view, item_strategy) | + st.builds(pa.large_list_view, item_strategy) + ) + return types @st.composite @@ -347,8 +351,7 @@ def arrays(draw, type, size=None, nullable=True): elif pa.types.is_timestamp(ty): if zoneinfo is None: pytest.skip('no module named zoneinfo (or tzdata on Windows)') - if ty.tz is None: - pytest.skip('requires timezone not None') + h.assume(ty.tz is not None) min_int64 = -(2**63) max_int64 = 2**63 - 1 min_datetime = datetime.datetime.fromtimestamp( @@ -421,7 +424,8 @@ def arrays(draw, type, size=None, nullable=True): value = st.one_of(st.none(), value) values = st.lists(value, min_size=size, max_size=size) - return pa.array(draw(values), type=ty) + actual_values = draw(values) + return pa.array(actual_values, type=ty) @st.composite diff --git a/python/pyarrow/tests/test_compute.py b/python/pyarrow/tests/test_compute.py index 1e08e73668e..d4f31de6178 100644 --- a/python/pyarrow/tests/test_compute.py +++ b/python/pyarrow/tests/test_compute.py @@ -27,6 +27,8 @@ import random import sys import textwrap +import hypothesis as h +import hypothesis.strategies as st try: import numpy as np @@ -41,6 +43,7 @@ import pyarrow as pa import pyarrow.compute as pc from pyarrow.lib import ArrowNotImplementedError, ArrowIndexError +import pyarrow.tests.strategies as past try: import pyarrow.substrait as pas @@ -4338,3 +4341,61 @@ def test_winsorize(): result = pc.winsorize( arr, options=pc.WinsorizeOptions(lower_limit=0.1, upper_limit=0.8)) assert result.to_pylist() == [8, 4, 8, 8, 5, 3, 7, 2, 2, 6] + + +hash_types = st.deferred( + lambda: ( + past.primitive_types | + past.list_types(include_views=False) | + past.struct_types() | + past.dictionary_types() | + past.map_types() | + past.list_types(hash_types, include_views=False) | + past.struct_types(hash_types) + ) +) + + +@pytest.mark.numpy +@h.given(past.arrays(hash_types)) +def test_hash32(arr): + result1 = pc.hash32(arr) + result2 = pc.hash32(arr) + assert result1.type == pa.uint32() + assert result1.equals(result2) + + +@pytest.mark.numpy +@h.given(past.arrays(hash_types)) +def test_hash64(arr): + result1 = pc.hash64(arr) + result2 = pc.hash64(arr) + assert result1.type == pa.uint64() + assert result1.equals(result2) + + +@st.composite +def hash_arrays_with_slice(draw): + arr = draw(past.arrays(hash_types)) + offset = draw(st.integers(min_value=0, max_value=len(arr))) + length = draw(st.integers(min_value=0, max_value=len(arr) - offset)) + return arr, offset, length + + +# Hashing a slice must match slicing the hash of the unsliced array: nested child +# arrays aren't resliced by Array.slice, so this exercises the offset handling for +# list/map/struct fields, not just plain values. +@pytest.mark.numpy +@h.given(hash_arrays_with_slice()) +def test_hash32_slice_consistency(args): + arr, offset, length = args + sliced = arr.slice(offset, length) + assert pc.hash32(sliced).equals(pc.hash32(arr).slice(offset, length)) + + +@pytest.mark.numpy +@h.given(hash_arrays_with_slice()) +def test_hash64_slice_consistency(args): + arr, offset, length = args + sliced = arr.slice(offset, length) + assert pc.hash64(sliced).equals(pc.hash64(arr).slice(offset, length)) diff --git a/r/tests/testthat/test-compute-aggregate.R b/r/tests/testthat/test-compute-aggregate.R index dd79682361b..d624e35c3bd 100644 --- a/r/tests/testthat/test-compute-aggregate.R +++ b/r/tests/testthat/test-compute-aggregate.R @@ -21,8 +21,11 @@ test_that("list_compute_functions", { justmins <- list_compute_functions("^min") expect_true(length(justmins) > 0) expect_all_true(grepl("min", justmins)) - no_hash_funcs <- list_compute_functions("^hash") + no_hash_funcs <- list_compute_functions("^hash_") expect_true(length(no_hash_funcs) == 0) + # hash32/hash64 are scalar functions (no underscore), unlike the hash_* + # group-by aggregations filtered out above, so they remain discoverable + expect_true(all(c("hash32", "hash64") %in% allfuncs)) }) test_that("sum.Array", {