jayzhan211 opened a new pull request, #26703:
URL: https://github.com/apache/datafusion/pull/26703

   ## Which issue does this PR close?
   
   - Part of #26696.
   
   ## Rationale for this change
   
   Batches often carry a dictionary with far more values than rows: each 
`batch_size` slice of a GROUP BY's output keeps every group's value, and 
repartitioning and filtering keep the whole dictionary too. Code that processes 
every dictionary value then does work proportional to the dictionary, not the 
batch, so queries slow down as the group count grows.
   
   On ClickBench `hits_partitioned` with 
`datafusion.execution.parquet.enable_rle_to_dictionary = true`:
   
   ```sql
   SELECT lower(s), c FROM (
     SELECT "SearchPhrase" s, COUNT(*) c FROM hits WHERE "SearchPhrase" <> '' 
GROUP BY s
   ) ORDER BY c DESC LIMIT 10;
   ```
   
   takes 12581 ms on main and 1019 ms with this PR: `lower` runs over every 
group's value once for each 8192-row output slice. Hashing has the same 
problem: the hash repartition after a partial aggregate hashes every group's 
value for each slice (#26696).
   
   ## What changes are included in this PR?
   
   Code that processes every value of a dictionary now handles at most 
`MAX_DICTIONARY_VALUES_PER_ROW` (8) values per row. Past that, it first takes 
the values the rows reference (`take(values, keys)` with keys `0..len`), which 
costs O(rows).
   
   - `datafusion_common::utils::map_dictionary_values(array, f)` applies `f` to 
a dictionary's values within that bound. The string functions that keep 
dictionaries (`ascii`, `btrim`, `ltrim`, `rtrim`, `lower`/`upper`, 
`character_length`, `initcap`, `reverse`), Spark `hex`, and `array_agg`'s 
payload compaction use it instead of each calling 
`dictionary.with_values(f(values))`.
   - Dictionary hashing (`hash_dictionary`, 
`hash_dictionary_with_child_hashing`) applies the same bound.
   - `clippy.toml` disallows calling `with_values` directly, so new code goes 
through the helper. `get_field` keeps a direct call because it only picks a 
child column.
   
   The threshold comes from the `with_hashes` benchmark. Just above 8 values 
per row, compacting before hashing is already faster than hashing every value: 
0.56x for short strings, 0.60x for `Int64` and 0.30x for 64-byte strings. At 16 
values per row it is 0.13x to 0.31x. Below the threshold the code path is 
unchanged.
   
   #24588 proposes evaluating opt-in functions once per distinct value in 
`ScalarFunctionExpr` and keeps the per-function dictionary arms. This PR bounds 
the cost of those arms and of hashing, so the two don't conflict.
   
   ## Benchmarks
   
   ClickBench `hits_partitioned`, `enable_rle_to_dictionary = true`, 12 
partitions, `release-nonlto`, minimum over 3 to 6 interleaved rounds (ms), same 
results on both:
   
   | query | main | this PR |
   |---|---|---|
   | `lower` over a dictionary GROUP BY (the query above) | 12581 | 1019 |
   | GROUP BY `"SearchPhrase"` (#26696) | 1194 | 867 |
   | GROUP BY `arrow_cast("SearchPhrase", 'Utf8')` | 463 | 476 |
   | `MAX(lower("SearchPhrase"))` | 634 | 631 |
   | GROUP BY `"MobilePhoneModel"` (low cardinality) | 123 | 125 |
   | `MAX(lower("MobilePhoneModel"))` | 160 | 159 |
   
   The rest of the #26696 gap (867 ms vs 463 ms for `Utf8` keys) is in arrow's 
`concat_dictionaries`, which the repartition coalescer uses, and in the general 
cost of dictionary group keys (#24822).
   
   `with_hashes`, 8192-row slices of a dictionary with one value per row of the 
full array, median of 5 interleaved runs (µs). Only `dictionary_sliced` is 
added to the benchmark; the threshold cases were run locally:
   
   | case | main | this PR |
   |---|---|---|
   | `dictionary_sliced`: 10 values per row | 70.5 | 39.7 |
   | short strings, 8 values per row (unchanged path) | 67.7 | 67.9 |
   | short strings, just over 8 values per row | 64.9 | 36.1 |
   | `Int64`, just over 8 values per row | 19.3 | 11.5 |
   | 64-byte strings, just over 8 values per row | 180.2 | 53.6 |
   | short strings, 16 values per row | 116.4 | 35.4 |
   | `Int64`, 16 values per row | 34.8 | 10.7 |
   | 64-byte strings, 16 values per row | 362.5 | 48.2 |
   
   The existing `dictionary_utf8_int32` cases (100 values per batch) take the 
unchanged path and stay within noise.
   
   ## What is the testing strategy for this PR?
   
   - `dictionary.slt`: a GROUP BY with `batch_size = 1` makes every output 
slice carry many more dictionary values than rows. The test compares each 
function above on the grouped dictionary with the same function on the decoded 
value, including a NULL group, and checks the result stays a dictionary. It 
also goes through the hash repartition of the partial aggregate's slices.
   - Unit tests: `create_hashes_for_dict_slices_with_more_values_than_rows` 
(single and multi-column hashes of slices with null keys and null values match 
the unsliced hashes) and `map_dictionary_values_bounds_values_per_row`.
   - Benchmark: the `dictionary_sliced` case in 
`datafusion/common/benches/with_hashes.rs`.
   
   ## Are there any user-facing changes?
   
   No behavior changes. `datafusion_common::utils` gains 
`map_dictionary_values` and `MAX_DICTIONARY_VALUES_PER_ROW`.
   


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