This is an automated email from the ASF dual-hosted git repository.

andygrove pushed a commit to branch branch-1.0
in repository https://gitbox.apache.org/repos/asf/datafusion-comet.git


The following commit(s) were added to refs/heads/branch-1.0 by this push:
     new e1102f488c fix: keep the dictionary hash fast path off nested and 
reseeded buffers (#5757) (#5817)
e1102f488c is described below

commit e1102f488c302456f0b7bd5f6dbe1e902d8597df
Author: Andy Grove <[email protected]>
AuthorDate: Wed Sep 9 17:23:19 2026 -0600

    fix: keep the dictionary hash fast path off nested and reseeded buffers 
(#5757) (#5817)
    
    The dictionary fast path hashes each distinct dictionary value once and 
reuses that
    result for every key. It was selected by the column's position, `i == 0`, 
and it
    restarted from a hardcoded seed of 42.
    
    Both parts are wrong. `create_hashes_internal!` also runs on recursion, so a
    dictionary nested in a list, struct or map arrives as the only column of 
its call and
    looks like a first column even though the buffer already holds the hash 
accumulated
    for earlier elements of that row. That hash was discarded, and a 
dictionary-encoded
    list element hashed differently from the identical decoded value. 
Separately, the
    hardcoded 42 is wrong whenever the caller supplies its own seed, as 
`hash(col, seed)`
    and `xxhash64(col, seed)` allow, so even a genuine first column disagreed 
with its
    decoded form for a non-default seed.
    
    The reuse is valid exactly when every row carries the same incoming hash, 
so that is
    what is now checked, and the per-value hashes start from the seed the 
buffer actually
    holds rather than an assumed 42. A top-level dictionary keeps the 
optimisation.
    
    The uniformity check is a scan of the hash buffer, which is measurable: 
running it for
    every column cost 17% on an int column and 11% on a string column in a local
    criterion benchmark. It is therefore done inside the dictionary arm, so only
    dictionary columns pay it and other types are untouched.
    
    Both hash implementations share this structure and both are fixed, with 
regression
    tests that fail without the change: a dictionary as a list element hashes 
3853467749
    rather than the 1401423033 of the decoded data.
    
    The single-row cases above pin the hardcoded seed but not the uniformity 
check, since
    one row is uniform by definition. Each algorithm therefore also gets a 
multi-row case
    whose incoming seeds all differ, which forces the unpacking fallback, and 
which
    includes a null key and a key pointing at a null dictionary value. Dropping 
the
    uniformity check leaves the other twenty hash tests green and fails exactly 
those two.
    
    
    (cherry picked from commit 92ad99e97482c861062f52372b172320197c2001)
    
    Co-authored-by: Liang-Chi Hsieh <[email protected]>
    Co-authored-by: Claude Code <[email protected]>
---
 native/spark-expr/src/hash_funcs/murmur3.rs  | 111 +++++++++++++++++++++++++--
 native/spark-expr/src/hash_funcs/utils.rs    |  20 ++++-
 native/spark-expr/src/hash_funcs/xxhash64.rs |  65 +++++++++++++++-
 3 files changed, 183 insertions(+), 13 deletions(-)

diff --git a/native/spark-expr/src/hash_funcs/murmur3.rs 
b/native/spark-expr/src/hash_funcs/murmur3.rs
index dc0f804ab2..233097ffc1 100644
--- a/native/spark-expr/src/hash_funcs/murmur3.rs
+++ b/native/spark-expr/src/hash_funcs/murmur3.rs
@@ -139,20 +139,23 @@ pub fn spark_compatible_murmur3_hash<T: 
AsRef<[u8]>>(data: T, seed: u32) -> u32
 fn create_hashes_dictionary<K: ArrowDictionaryKeyType>(
     array: &ArrayRef,
     hashes_buffer: &mut [u32],
-    first_col: bool,
+    seeds_are_pristine: bool,
 ) -> datafusion::common::Result<()> {
     let dict_array = 
array.as_any().downcast_ref::<DictionaryArray<K>>().unwrap();
-    if !first_col {
+    if !seeds_are_pristine {
         // unpack the dictionary array as each row may have a different hash 
input
         let unpacked = take(dict_array.values().as_ref(), dict_array.keys(), 
None)?;
         create_murmur3_hashes(&[unpacked], hashes_buffer)?;
     } else {
-        // For the first column, hash each dictionary value once, and then use
-        // that computed hash for each key value to avoid a potentially
-        // expensive redundant hashing for large dictionary elements (e.g. 
strings)
+        // Every row still carries the untouched seed, so each distinct 
dictionary value hashes to
+        // the same result no matter which row it appears in. Hash each value 
once and reuse it per
+        // key, which avoids redundant hashing of large dictionary elements 
(e.g. strings).
         let dict_values = Arc::clone(dict_array.values());
-        // same initial seed as Spark
-        let mut dict_hashes = vec![42; dict_values.len()];
+        // Seed from the buffer rather than assuming Spark's 42: `hash(col, 
seed)` lets the caller
+        // choose, and the reuse is only sound if the per-value hashes start 
from the same seed the
+        // rows carry. The caller guarantees the buffer is uniform, so any 
row's value will do.
+        let seed = hashes_buffer.first().copied().unwrap_or(42);
+        let mut dict_hashes = vec![seed; dict_values.len()];
         create_murmur3_hashes(&[dict_values], &mut dict_hashes)?;
         for (hash, key) in 
hashes_buffer.iter_mut().zip(dict_array.keys().iter()) {
             if let Some(key) = key {
@@ -205,6 +208,100 @@ mod tests {
         test_hashes_with_nulls!(create_murmur3_hashes, T, values, expected, 
u32);
     }
 
+    /// A dictionary array reached through a nested type arrives as the only 
column of its recursive
+    /// call, so deciding the dictionary fast path from column position alone 
treated it as a first
+    /// column and restarted from the seed, discarding the hash accumulated 
for earlier elements of
+    /// the same row. The result differed from the identical decoded data.
+    #[test]
+    fn test_dictionary_element_in_list_matches_decoded() {
+        use arrow::array::{DictionaryArray, Int32Array, ListArray};
+        use arrow::buffer::OffsetBuffer;
+        use arrow::datatypes::{Field, Int8Type};
+
+        let values: ArrayRef = Arc::new(Int32Array::from(vec![10, 20]));
+        let keys = arrow::array::Int8Array::from(vec![0i8, 1]);
+        let dict: ArrayRef = 
Arc::new(DictionaryArray::<Int8Type>::try_new(keys, values).unwrap());
+        let decoded: ArrayRef = Arc::new(Int32Array::from(vec![10, 20]));
+
+        // One row holding both elements, so the second element's hash chains 
onto the first.
+        let as_list = |elems: ArrayRef| -> ArrayRef {
+            Arc::new(ListArray::new(
+                Arc::new(Field::new("item", elems.data_type().clone(), true)),
+                OffsetBuffer::new(vec![0i32, 2].into()),
+                elems,
+                None,
+            ))
+        };
+
+        let mut from_dict = vec![42u32; 1];
+        create_murmur3_hashes(&[as_list(dict)], &mut from_dict).unwrap();
+        let mut from_decoded = vec![42u32; 1];
+        create_murmur3_hashes(&[as_list(decoded)], &mut from_decoded).unwrap();
+
+        assert_eq!(
+            from_dict, from_decoded,
+            "a dictionary-encoded list element must hash like the decoded 
value"
+        );
+    }
+
+    /// The fast path must survive for a genuine first column, including one 
with a caller-supplied
+    /// seed that is not Spark's 42, since the test for it is that every row 
is seeded alike.
+    #[test]
+    fn test_top_level_dictionary_matches_decoded() {
+        use arrow::array::{DictionaryArray, Int32Array};
+        use arrow::datatypes::Int8Type;
+
+        let values: ArrayRef = Arc::new(Int32Array::from(vec![10, 20]));
+        let keys = arrow::array::Int8Array::from(vec![0i8, 1, 0]);
+        let dict: ArrayRef = 
Arc::new(DictionaryArray::<Int8Type>::try_new(keys, values).unwrap());
+        let decoded: ArrayRef = Arc::new(Int32Array::from(vec![10, 20, 10]));
+
+        for seed in [42u32, 7u32] {
+            let mut a = vec![seed; 3];
+            create_murmur3_hashes(&[Arc::clone(&dict)], &mut a).unwrap();
+            let mut b = vec![seed; 3];
+            create_murmur3_hashes(&[Arc::clone(&decoded)], &mut b).unwrap();
+            assert_eq!(
+                a, b,
+                "top-level dictionary must match decoded for seed {seed}"
+            );
+        }
+    }
+
+    /// The uniformity check is what makes the fast path safe, and a 
single-row case cannot pin it:
+    /// one row is trivially uniform. This hashes several rows whose incoming 
seeds all differ, so a
+    /// dictionary first column has to take the unpacking fallback. It also 
covers a null key and a
+    /// key pointing at a null dictionary value, since both skip the hash 
update.
+    #[test]
+    fn test_dictionary_with_nonuniform_seeds_matches_decoded() {
+        use arrow::array::{DictionaryArray, Int32Array};
+        use arrow::datatypes::Int8Type;
+
+        // values[2] is null, and one key is itself null
+        let values: ArrayRef = Arc::new(Int32Array::from(vec![Some(10), 
Some(20), None]));
+        let keys = arrow::array::Int8Array::from(vec![Some(0), Some(1), 
Some(2), None, Some(0)]);
+        let dict: ArrayRef = 
Arc::new(DictionaryArray::<Int8Type>::try_new(keys, values).unwrap());
+        // The same logical data with the dictionary resolved.
+        let decoded: ArrayRef = Arc::new(Int32Array::from(vec![
+            Some(10),
+            Some(20),
+            None,
+            None,
+            Some(10),
+        ]));
+
+        let seeds: Vec<u32> = vec![7, 38, 69, 100, 131];
+        let mut from_dict = seeds.clone();
+        create_murmur3_hashes(&[dict], &mut from_dict).unwrap();
+        let mut from_decoded = seeds;
+        create_murmur3_hashes(&[decoded], &mut from_decoded).unwrap();
+
+        assert_eq!(
+            from_dict, from_decoded,
+            "with per-row seeds a dictionary must hash like the decoded array"
+        );
+    }
+
     #[test]
     fn test_i8() {
         test_murmur3_hash::<i8, Int8Array>(
diff --git a/native/spark-expr/src/hash_funcs/utils.rs 
b/native/spark-expr/src/hash_funcs/utils.rs
index 979f787b59..18e5a41bc7 100644
--- a/native/spark-expr/src/hash_funcs/utils.rs
+++ b/native/spark-expr/src/hash_funcs/utils.rs
@@ -576,6 +576,15 @@ macro_rules! create_hashes_internal {
         use arrow::array::{types::*, *};
 
         for (i, col) in $arrays.iter().enumerate() {
+            // The dictionary fast path hashes each distinct dictionary value 
once and reuses that
+            // result for every key, which is only valid while every row 
carries the same incoming
+            // hash. Position in the column list is not a sufficient test: 
this macro also runs on
+            // recursion, where a nested dictionary arrives as the only column 
of its call even
+            // though the buffer already holds the hash accumulated for that 
row -- a
+            // dictionary-encoded list element, for instance. So confirm the 
buffer is uniform,
+            // which keeps the optimisation for a genuine first column (every 
row seeded alike,
+            // whatever the seed) and unpacks otherwise. Only dictionaries 
need this, and the scan
+            // is measurable on the hot path, so it is deferred into the 
dictionary arm below.
             let first_col = i == 0;
             match col.data_type() {
                 DataType::Boolean => {
@@ -729,7 +738,13 @@ macro_rules! create_hashes_internal {
                 DataType::Decimal128(_, _) => {
                     $crate::hash_array_decimal!(Decimal128Array, col, 
$hashes_buffer, $hash_method);
                 }
-                DataType::Dictionary(index_type, _) => match **index_type {
+                DataType::Dictionary(index_type, _) => {
+                    let first_col = first_col
+                        && match $hashes_buffer.first() {
+                            None => true,
+                            Some(first) => $hashes_buffer.iter().all(|h| h == 
first),
+                        };
+                    match **index_type {
                     DataType::Int8 => {
                         $create_dictionary_hash_method::<Int8Type>(col, 
$hashes_buffer, first_col)?;
                     }
@@ -788,7 +803,8 @@ macro_rules! create_hashes_internal {
                             col.data_type(),
                         )))
                     }
-                },
+                    }
+                }
                 DataType::List(field) => {
                     let list_array = 
col.as_any().downcast_ref::<ListArray>().unwrap();
                     let values = list_array.values();
diff --git a/native/spark-expr/src/hash_funcs/xxhash64.rs 
b/native/spark-expr/src/hash_funcs/xxhash64.rs
index c9d0f93ef8..7009fc99c2 100644
--- a/native/spark-expr/src/hash_funcs/xxhash64.rs
+++ b/native/spark-expr/src/hash_funcs/xxhash64.rs
@@ -85,10 +85,10 @@ fn spark_compatible_xxhash64<T: AsRef<[u8]>>(data: T, seed: 
u64) -> u64 {
 fn create_xxhash64_hashes_dictionary<K: ArrowDictionaryKeyType>(
     array: &ArrayRef,
     hashes_buffer: &mut [u64],
-    first_col: bool,
+    seeds_are_pristine: bool,
 ) -> Result<()> {
     let dict_array = 
array.as_any().downcast_ref::<DictionaryArray<K>>().unwrap();
-    if !first_col {
+    if !seeds_are_pristine {
         let unpacked = take(dict_array.values().as_ref(), dict_array.keys(), 
None)?;
         create_xxhash64_hashes(&[unpacked], hashes_buffer)?;
     } else {
@@ -96,8 +96,11 @@ fn create_xxhash64_hashes_dictionary<K: 
ArrowDictionaryKeyType>(
         // hash for each key value to avoid a potentially expensive
         // redundant hashing for large dictionary elements (e.g. strings)
         let dict_values = Arc::clone(dict_array.values());
-        // same initial seed as Spark
-        let mut dict_hashes = vec![42u64; dict_values.len()];
+        // Seed from the buffer rather than assuming Spark's 42: 
`xxhash64(col, seed)` lets the
+        // caller choose, and the reuse is only sound if the per-value hashes 
start from the same
+        // seed the rows carry. The caller guarantees the buffer is uniform.
+        let seed = hashes_buffer.first().copied().unwrap_or(42u64);
+        let mut dict_hashes = vec![seed; dict_values.len()];
         create_xxhash64_hashes(&[dict_values], &mut dict_hashes)?;
 
         for (hash, key) in 
hashes_buffer.iter_mut().zip(dict_array.keys().iter()) {
@@ -151,6 +154,60 @@ mod tests {
         test_hashes_with_nulls!(create_xxhash64_hashes, T, values, expected, 
u64);
     }
 
+    /// The dictionary fast path is shared in shape with murmur3, so it has 
the same requirement:
+    /// a dictionary reached through a nested type must not restart from the 
seed.
+    #[test]
+    fn test_dictionary_element_in_list_matches_decoded() {
+        use arrow::array::{DictionaryArray, ListArray};
+        use arrow::buffer::OffsetBuffer;
+        use arrow::datatypes::{Field, Int8Type};
+
+        let values: ArrayRef = Arc::new(Int32Array::from(vec![10, 20]));
+        let keys = arrow::array::Int8Array::from(vec![0i8, 1]);
+        let dict: ArrayRef = 
Arc::new(DictionaryArray::<Int8Type>::try_new(keys, values).unwrap());
+        let decoded: ArrayRef = Arc::new(Int32Array::from(vec![10, 20]));
+
+        let as_list = |elems: ArrayRef| -> ArrayRef {
+            Arc::new(ListArray::new(
+                Arc::new(Field::new("item", elems.data_type().clone(), true)),
+                OffsetBuffer::new(vec![0i32, 2].into()),
+                elems,
+                None,
+            ))
+        };
+
+        let mut from_dict = vec![42u64; 1];
+        create_xxhash64_hashes(&[as_list(dict)], &mut from_dict).unwrap();
+        let mut from_decoded = vec![42u64; 1];
+        create_xxhash64_hashes(&[as_list(decoded)], &mut 
from_decoded).unwrap();
+        assert_eq!(from_dict, from_decoded);
+    }
+
+    /// Companion to the murmur3 test: per-row seeds force the unpacking 
fallback here too.
+    #[test]
+    fn test_dictionary_with_nonuniform_seeds_matches_decoded() {
+        use arrow::array::DictionaryArray;
+        use arrow::datatypes::Int8Type;
+
+        let values: ArrayRef = Arc::new(Int32Array::from(vec![Some(10), 
Some(20), None]));
+        let keys = arrow::array::Int8Array::from(vec![Some(0), Some(1), 
Some(2), None, Some(0)]);
+        let dict: ArrayRef = 
Arc::new(DictionaryArray::<Int8Type>::try_new(keys, values).unwrap());
+        let decoded: ArrayRef = Arc::new(Int32Array::from(vec![
+            Some(10),
+            Some(20),
+            None,
+            None,
+            Some(10),
+        ]));
+
+        let seeds: Vec<u64> = vec![7, 38, 69, 100, 131];
+        let mut from_dict = seeds.clone();
+        create_xxhash64_hashes(&[dict], &mut from_dict).unwrap();
+        let mut from_decoded = seeds;
+        create_xxhash64_hashes(&[decoded], &mut from_decoded).unwrap();
+        assert_eq!(from_dict, from_decoded);
+    }
+
     #[test]
     fn test_i8() {
         test_xxhash64_hash::<i8, Int8Array>(


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to