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

   ## Which issue does this PR close?
   
   Related to #20935, which asks for exactly this: *"Ideally this would be a
   reusable pattern (perhaps a helper or wrapper) that individual string 
functions
   can opt into, rather than duplicating the logic in every function."*
   
   Related to #19458; its first half — delivering the dictionary to the 
function —
   merged as #22905. This is the other half: using what is delivered, once for
   every function.
   
   Note: this branch includes the benchmark commit from #24586 until it merges.
   
   ## Rationale for this change
   
   A scalar function over a dictionary-encoded column runs once per row, 
although
   the batch holds no more distinct values than the dictionary does. Eleven
   functions already work around this with hand-written handling in their 
kernels:
   
   ```rust
   // datafusion/functions/src/unicode/reverse.rs
   DataType::Dictionary(_, _) => {
       let dictionary = args[0].as_any_dictionary();
       let converted = reverse(&[Arc::clone(dictionary.values())])?;
       Ok(dictionary.with_values(converted))
   }
   ```
   
   That arm sits in nine functions across eight files, with a
   `ScalarValue::Dictionary` twin in `bit_length` and `octet_length`. Every new
   function writes it again, and a function without one — `encode`, the hashes,
   the regex functions — cannot have the optimization at all. This does it once,
   in `ScalarFunctionExpr`, for any function that declares the property.
   
   ## What changes are included in this PR?
   
   `ScalarUDFImpl::evaluates_elementwise` (default `false`) declares that each
   output row depends only on the corresponding input row. Over a dictionary
   argument the physical layer then unwraps the call and re-maps the result
   through the keys, choosing the cheapest sound tier per batch:
   
   - **remembered** — these values were evaluated before; nothing is computed;
   - **as they are** — values passed unchanged when no key is null or `f` is 
strict;
   - **compacted** — only the values the batch references, null keys redirected 
to
     one appended NULL slot (correct even where `f(NULL)` is not NULL);
   - **expanded** — flat returns only: the materialized column, exactly what
     coercion produces today. A dictionary return declines instead and the
     function sees the column as it arrived.
   
   Results are reused across batches because a batch carries its own keys but 
the
   dictionary of its whole column chunk — the Parquet reader hands every batch 
of
   a chunk the same value buffers. Results are keyed on the memory the values
   occupy and hold it alive, bounded to eight dictionaries and 4 MiB per
   expression (a Parquet dictionary page is at most 1 MiB by default). Hits take
   only a read lock, so the partitions sharing an expression do not serialize on
   each other; a first sighting records a hash, and the second, which proves the
   dictionary repeats, buys evaluating all of it.
   
   Three functions opt in: **`encode`** (had no dictionary handling and could 
not
   have; gains encoding preservation plus the declaration, kernel untouched),
   **`reverse`** and **`initcap`** (arms since #23930; gain only the 
declaration).
   The arms stay: the physical layer still hands the dictionary over wherever it
   declines — extension metadata, a key type too narrow for the NULL slot.
   
   ## Are these changes tested?
   
   26 unit tests in `scalar_function.rs` cover each tier and its boundaries —
   null keys under strict and non-strict functions, garbage under null keys, the
   NULL slot overflowing a narrow key type, the profitability bound, two
   dictionary arguments, dictionary scalars, extension metadata, errors from
   referenced vs unreferenced values, memoization across batches but not across
   dictionaries or past its byte budget, and concurrent hits over one shared
   expression. `functions.slt` adds the narrow-key decline for `reverse` end to
   end, and existing dictionary coverage for `reverse`/`initcap` now runs 
through
   the generic path unchanged. For `encode`, `expr.slt` pins results and the
   plan — the cast is to `Dictionary(Int32, BinaryView)`, not away from the
   encoding.
   
   ## Are there any user-facing changes?
   
   A new trait method with a default; no existing implementation changes. All
   three functions return exactly what they did — only how often they compute it
   changes.
   
   ## Benchmarks
   
   The benchmark lands separately in #24586, measuring today's paths — the
   hand-written arm (whose cold and warm batches cost the same, an arm cannot
   reuse anything) and the cast every unpreserved function pays. This PR extends
   it with `encode`'s dictionary-typed groups, which only become expressible
   here. 8192-row batches, medians, pinned to one core. `cold`: a dictionary per
   batch; `warm`: one shared across batches, as a Parquet column chunk delivers
   them. *no preservation*: the dictionary cast away, one call per row — what
   `encode` did before this change (`reverse`'s arm already costs about the cold
   column).
   
   | encode, per batch | cold | warm | no preservation |
   |---|---:|---:|---:|
   | 8 distinct values | 68 µs | 44 µs | 585 µs |
   | 256 distinct values | 77 µs | 44 µs | 546 µs – 1.10 ms |
   | 512 distinct values | 108 µs | 43 µs | 589 µs |
   | 8192 distinct, all different | 0.87–1.09 ms | 44 µs | 584 µs |
   
   | reverse, per batch | cold | warm | no preservation |
   |---|---:|---:|---:|
   | 8 distinct values | 1.75 µs | 0.69 µs | 288 µs |
   | 256 distinct values | 10.0 µs | 0.70 µs | 291 µs |
   | 512 distinct values | 17.1 µs | 0.68 µs | 285 µs |
   | 8192 distinct, all different | 242 µs | 0.68 µs | 287 µs |
   
   Two cells above are ranges because they are bimodal across repeated runs of
   the same binary, and the bimodality is a property of the heap, not of either
   path: `encode`'s expand tier at full cardinality measured 0.87–1.09 ms across
   run contexts, and the *cast path itself* flipped the same way at 256 distinct
   (546 µs alone, 1.10 ms inside the full suite) while doing byte-identical work
   to its stable neighbours. Both paths allocate ~450 KB per batch there; the
   mechanism's own work in that cell is microseconds (an aborted compaction scan
   and a hash), and a column with no repeated values should not be
   dictionary-encoded in the first place. Against the hand-written arms the
   mechanism costs a flat ~0.6–0.9 µs per cold batch and repays it on the first
   repeated dictionary; per-function numbers in the first comment.
   
   ## What this does not do
   
   - The pass/compact threshold assumes a value costs ~7 ns; cheap functions can
     lose above the batch size (`ascii` up to 4.6x in that band). A per-value 
cost
     model does not exist yet, so the remaining nine arms should be measured
     before opting in, not converted in bulk.
   - The eleven functions keep their hand-written handling; removing it is a
     separate change.
   - FFI is untouched: foreign UDFs inherit the default and lose only the
     optimization. Tracked by #22330.
   - Multiple dictionary arguments, run-end encoding, and extension-typed fields
     take the unpeeled path.
   
   🤖 Generated with [Claude Code](https://claude.com/claude-code)
   
   https://claude.ai/code/session_01NysGeXTG5opiJKBApsAe5H
   


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