sunchao commented on code in PR #5778:
URL: https://github.com/apache/datafusion-comet/pull/5778#discussion_r3962023463


##########
native/spark-expr/src/hash_funcs/utils.rs:
##########
@@ -526,34 +526,111 @@ macro_rules! hash_list_array {
         let values = list_array.values();
         let offsets = list_array.offsets();
 
-        if list_array.null_count() == 0 {
-            // Fast path: no nulls, skip null checks
-            for (row_idx, hash) in $hashes.iter_mut().enumerate() {
-                let start = offsets[row_idx] as usize;
-                let end = offsets[row_idx + 1] as usize;
-                let len = end - start;
-                // Hash each element in sequence, chaining the hash values
-                for elem_idx in 0..len {
-                    let elem_array = values.slice(start + elem_idx, 1);
-                    let mut single_hash = [*hash];
-                    $recursive_hash_method(&[elem_array], &mut single_hash)?;
-                    *hash = single_hash[0];
+        // Spark chains the element hashes in order, so the elements of one 
row have to be hashed
+        // in sequence. What does not have to happen per element is the 
allocation and dispatch:
+        // slicing a one-element array and re-entering the hash dispatch for 
it costs an Arrow
+        // array plus a full type match every time, and for a struct element 
the dispatch also
+        // copies the field vector on every call.
+        //
+        // Instead, hash one element per row at a time in a single batched 
call, seeding each
+        // slot with the running hash of the row it belongs to. That is 
exactly what the
+        // per-element call did, so the result is bit-identical.
+        let total_elements = offsets[$hashes.len()] as usize - offsets[0] as 
usize;
+        if total_elements == 0 {
+            // Every list is empty or null; the seeds already hold the answer.
+        } else {
+            let first_offset = offsets[0] as usize;
+            let elements = values.slice(first_offset, total_elements);
+
+            // Chaining means element k of a row can only be hashed once 
element k-1 is known, so
+            // batch by position: all the first elements together, then all 
the second, and so on.
+            // Rows are independent, so one pass per position is enough.
+            //
+            // Only rows that still have an element at the current position 
take part, and a row
+            // never becomes alive again once exhausted, so carry the 
surviving rows forward instead
+            // of rescanning all of them each pass. Rescanning would cost rows 
x longest-list, which
+            // for one long list among short ones is almost all wasted: 8192 
rows with one list of
+            // 1024 scans 8.4M slots for 9215 elements. Carrying the survivors 
makes the scheduling
+            // work proportional to the elements actually hashed.
+            //
+            // Index the gather by the list's own offset width. A `LargeList` 
can hold more than
+            // `u32::MAX` elements, so narrowing the positions to `u32` would 
silently wrap and
+            // hash the wrong elements.
+            let mut active: Vec<usize> = Vec::with_capacity($hashes.len());
+            // The same pass records whether every row is non-null with the 
same length. When it
+            // is, no row ever drops out early, so the survivor bookkeeping is 
pure overhead and
+            // the rows can simply be walked directly.
+            let mut uniform_len: Option<usize> = None;
+            let mut all_same = true;
+            for row_idx in 0..$hashes.len() {
+                if list_array.is_null(row_idx) {
+                    all_same = false;
+                    continue;
+                }
+                let len = offsets[row_idx + 1] as usize - offsets[row_idx] as 
usize;
+                if len > 0 {
+                    active.push(row_idx);
+                }
+                match uniform_len {
+                    None => uniform_len = Some(len),
+                    Some(seen) if seen == len => {}
+                    Some(_) => all_same = false,
                 }
             }
-        } else {
-            // Slow path: array has nulls, check each row
-            for (row_idx, hash) in $hashes.iter_mut().enumerate() {
-                if !list_array.is_null(row_idx) {
-                    let start = offsets[row_idx] as usize;
-                    let end = offsets[row_idx + 1] as usize;
-                    let len = end - start;
-                    // Hash each element in sequence, chaining the hash values
-                    for elem_idx in 0..len {
-                        let elem_array = values.slice(start + elem_idx, 1);
-                        let mut single_hash = [*hash];
-                        $recursive_hash_method(&[elem_array], &mut 
single_hash)?;
-                        *hash = single_hash[0];
+            let uniform = all_same && uniform_len.unwrap_or(0) > 0;
+
+            let mut positions: Vec<$offset_type> = 
Vec::with_capacity($hashes.len());
+            let mut rows_at_position: Vec<usize> = 
Vec::with_capacity($hashes.len());
+            let mut still_active: Vec<usize> = 
Vec::with_capacity($hashes.len());
+            let mut position = 0usize;
+            let uniform_passes = if uniform { uniform_len.unwrap_or(0) } else 
{ 0 };
+            while (uniform && position < uniform_passes) || (!uniform && 
!active.is_empty()) {
+                positions.clear();
+                rows_at_position.clear();
+                if uniform {
+                    // Every row survives every pass, so skip the survivor 
bookkeeping.
+                    for row_idx in active.iter().copied() {
+                        let start = offsets[row_idx] as usize;
+                        positions.push((start + position - first_offset) as 
$offset_type);
+                        rows_at_position.push(row_idx);
                     }
+                } else {
+                    still_active.clear();
+                    for row_idx in active.iter().copied() {
+                        let start = offsets[row_idx] as usize;
+                        let end = offsets[row_idx + 1] as usize;
+                        positions.push((start + position - first_offset) as 
$offset_type);
+                        rows_at_position.push(row_idx);
+                        // Alive for the next pass only if it has an element 
beyond this one.
+                        if start + position + 1 < end {
+                            still_active.push(row_idx);
+                        }
+                    }
+                    std::mem::swap(&mut active, &mut still_active);
+                }
+                position += 1;
+                // `take` accepts any integer index type, so index by the 
offset width: a
+                // `LargeList` can exceed `u32::MAX` elements.
+                let taken = if std::mem::size_of::<$offset_type>() > 4 {
+                    let indices = arrow::array::Int64Array::from_iter_values(
+                        positions.iter().map(|p| *p as i64),
+                    );
+                    arrow::compute::take(&elements, &indices, None)?
+                } else {
+                    let indices = arrow::array::Int32Array::from_iter_values(
+                        positions.iter().map(|p| *p as i32),
+                    );
+                    arrow::compute::take(&elements, &indices, None)?

Review Comment:
   ### Performance
   
   [P2] Measure the gather-heavy paths before replacing the generic fallback
   
   Both branches now call `take` on every surviving position. Arrow 59.3.0 
[gathers every struct 
child](https://github.com/apache/arrow-rs/blob/59.3.0/arrow-select/src/take.rs#L272-L299)
 and [copies selected list child 
ranges](https://github.com/apache/arrow-rs/blob/59.3.0/arrow-select/src/take.rs#L648-L729),
 whereas the old one-element slices shared those payloads. This adds data 
copying for large inner arrays/strings, including child data beneath null 
structs. When only one row remains active, it also saves no recursive 
dispatches for that tail.
   
   The reported cases use 8,192 non-null rows and tiny struct strings. Please 
add matched base/head timings and allocated/peak temporary memory for the 
existing `list_of_list` and `list_of_struct_of_map` cases, large inner 
payloads, small batches/single-survivor tails, varying null density and 
dictionary-backed nested values. Pin the compared builds, check identical 
hashes, and retain a slice/direct path where gathering loses. The 
nested-shuffle flag does not protect ordinary `hash`/`xxhash64` expressions 
from this change. This is a verified coverage gap around newly introduced 
copying, not a measured slowdown claim.
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to