jayzhan211 opened a new issue, #11885:
URL: https://github.com/apache/arrow-rs/issues/11885

   ### Is your feature request related to a problem or challenge?
   
   When `concat` merges dictionaries (`concat_dictionaries` → 
`merge_dictionary_values`), it builds state sized to each input's whole values 
array, even when the input is a short slice that references only a few of those 
values:
   
   - `compute_values_mask(keys, key_mask, values.len())` builds a 
`values.len()`-bit mask for each input (`arrow-select/src/dictionary.rs:252`), 
and `get_masked_values` walks it (`dictionary.rs:254`).
   - The key mapping for each input is `vec![zero; dictionary.values().len()]` 
(`dictionary.rs:272`).
   
   So concatenating slices costs time proportional to their dictionaries, not 
to their rows. This shows up when the inputs are slices of large dictionaries. 
For example, DataFusion's hash aggregation emits all of its groups as one 
dictionary and slices it into `batch_size` batches, and the repartition 
coalescer then concatenates pieces of those slices from several partitions 
(apache/datafusion#26696).
   
   The repro below (arrow 60.0.0) concatenates 12 slices of 682 rows each, 8184 
rows in total. Each slice comes from a different `Dictionary(Int32, Utf8)` 
whose values are all distinct, so the dictionaries are merged:
   
   ```rust
   use arrow_array::types::Int32Type;
   use arrow_array::{Array, ArrayRef, DictionaryArray, Int32Array, StringArray};
   use arrow_select::concat::concat;
   use std::sync::Arc;
   use std::time::Instant;
   
   fn dictionary(values: usize, seed: usize) -> DictionaryArray<Int32Type> {
       let v = StringArray::from_iter_values((0..values).map(|i| 
format!("p{seed}_value_{i}")));
       let keys = Int32Array::from_iter_values(0..values as i32);
       DictionaryArray::try_new(keys, Arc::new(v)).unwrap()
   }
   
   fn main() {
       let (pieces, rows) = (12, 682);
       for values in [1_000, 10_000, 100_000, 1_000_000] {
           let dicts: Vec<_> = (0..pieces).map(|p| dictionary(values, 
p)).collect();
           let slices: Vec<ArrayRef> = dicts
               .iter()
               .map(|d| Arc::new(d.slice((values - rows) / 2, rows)) as 
ArrayRef)
               .collect();
           let inputs: Vec<&dyn Array> = slices.iter().map(|a| 
a.as_ref()).collect();
           let start = Instant::now();
           for _ in 0..100 {
               concat(&inputs).unwrap();
           }
           println!("{values}: {:?} per concat", start.elapsed() / 100);
       }
   }
   ```
   
   | values per input dictionary | `concat` of the 12 slices (µs) | the same 
rows as `Utf8` (µs) |
   |---|---|---|
   | 1,000 | 121–141 | 3 |
   | 10,000 | 131–134 | 3 |
   | 100,000 | 150–153 | 3 |
   | 1,000,000 | 426–438 | 3 |
   
   The output always has 8184 rows, but the time grows with the size of the 
input dictionaries. The table shows the best of 15 runs of 20 iterations 
(release build, Apple M4 Pro, two rounds); the snippet's plain average gives 
about the same numbers after a warm-up run.
   
   ### Describe the solution you'd like
   
   When an input has far more values than rows, keep state only for the values 
its rows reference. For example, collect the referenced keys (O(rows)) and 
build the mapping and the masked values from them, instead of 
`values.len()`-sized buffers. Inputs whose dictionary isn't much larger than 
their rows keep the current dense path.
   
   ### Describe alternatives you've considered
   
   - **Compacting oversized inputs before merging** (`take(values, keys)` with 
keys `0..len`) in `concat_dictionaries`. I tried this: it brings the 1,000,000 
row down to 155 µs, but it makes the small cases 15–25% slower (150–164 µs at 
1,000 values). Copying the values costs more than the dense mapping until the 
dictionary is very large, so this would need a high threshold.
   - **`garbage_collect_dictionary` on each input first.** It also allocates a 
`values.len()` remap per input (`dictionary.rs:59`).
   - **Compacting in each caller before concatenating.** This works, but every 
caller that slices dictionaries would have to do it.
   
   ### Additional context
   
   - #9939 is the same kind of problem in `interleave_fallback_dictionary`, the 
path that doesn't merge. `interleave_dictionaries` (`interleave.rs:304`) also 
calls `merge_dictionary_values` and keeps the same per-input `values.len()` 
state.
   - In the DataFusion query from apache/datafusion#26696, each piece carries 
about 600k values (7.25M partial groups over 12 partitions). Compacting 
oversized inputs in `concat_dictionaries` took the repartition's `send_time` 
from 3.73 s to 1.94 s (summed over 12 partitions) and the query from 878 ms to 
708 ms.
   - Merging is slower than concatenating the equivalent `Utf8` even for small 
dictionaries (about 130 µs vs 3 µs above), because every referenced value is 
interned. This issue is only about the part that grows with the dictionary size.
   
   I'm not planning to fix this myself, so anyone is welcome to pick it up. 
Feel free to ping me with questions about the cause or when you'd like a review.
   


-- 
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]

Reply via email to