perf: preserve dictionary encoding for lower/upper to avoid materializing low-cardinality columns#22905
perf: preserve dictionary encoding for lower/upper to avoid materializing low-cardinality columns#22905lyne7-sc wants to merge 7 commits into
Conversation
|
Thank you for opening this pull request! Reviewer note: cargo-semver-checks reported the current version number is not SemVer-compatible with the changes in this pull request (compared against the base branch). Details |
Jefffrey
left a comment
There was a problem hiding this comment.
just an initial comment, hope to fully review it soon
| /// Controls whether a [`Coercion`] preserves an argument's physical encoding | ||
| /// (e.g. dictionary) instead of materializing it to the coerced value type. | ||
| #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Hash)] | ||
| pub enum EncodingPreservation { | ||
| /// Do not request preservation of a physical encoding. | ||
| None, | ||
| /// Preserve dictionary encoding and coerce only the dictionary values. | ||
| Dictionary, | ||
| } |
There was a problem hiding this comment.
Something to consider is if an enum is best suited for this 🤔
For example, if we want to also include run encoded arrays as well (since they are similar to dictionaries), would this mean two new variants, one just for run arrays and one for dictionary + run arrays?
- I don't know if arrow is planning to include any more types of encodings like this at the moment
So I was also thinking perhaps a bitflag approach (or just a struct with boolean flags) could be another approach:
struct Encoding {
preserve_dictionary: bool,
preserve_run: bool,
}But I don't know how common a use case would be to implement preservation only for dictionaries and not run arrays (or vice versa) so maybe thats overengineering 🤔
There was a problem hiding this comment.
Considering RunEndEncoded and other potential encoding types, I agree that using a struct with boolean flags is a better approach. It keeps the complexity low while making it easier to add future preservation options without introducing enum variants for every possible combination.
| } | ||
|
|
||
| #[test] | ||
| fn test_coercible_dictionary_preserves_encoding() -> Result<()> { |
There was a problem hiding this comment.
Could we also have a test for a TypeSignatureClass that isn't native? curious to see how it'll work, since #19458 highlighted that current behaviour for dictionaries is already different for TypeSignatureClass::Native and non-native (e.g. TypeSignatureClass::Integer)
There was a problem hiding this comment.
Added tests for both cases:
The behavior now looks like this:
| TypeSignatureClass | no preservation | dictionary preservation |
|---|---|---|
Native(Int64) |
Int64 |
Dictionary(Int8, Int64) |
Non-Native(Integer) |
Dictionary(Int8, Int32) |
Dictionary(Int8, Int32) |
There was a problem hiding this comment.
Thanks for checking this; I'm a little worried because of the discrepancy here now; is it a big task to try align them? Though we might need to check the UDFs of existing functions that use a non-native typesignatureclass in case they already knew this assumption and had dictionary paths 🤔
There was a problem hiding this comment.
It looks like this discrepancy already exists today. Typed non-Native TypeSignatureClass values can pass Dictionary through, while Native coercions materialize to the target type. But this pr makes that contrast more visible by adding an explicit preservation API for the Native path.
If we want EncodingPreservation to be the general mechanism for deciding whether Dictionary is preserved, then I agree we should also think about typed non-Native TypeSignatureClass values. I took a quick pass over the affected built-ins. The good news is that I did not find an obvious core DataFusion function in the affected typed non-Native TypeSignatureClass path that intentionally depends on receiving Dictionary(_, T)🙂. Most seem to dispatch on T directly, and some would likely reject Dictionary(_, T) today after type coercion has accepted it. From that angle, aligning the default behavior may actually fix cases where dictionary inputs currently pass signature matching but fail later in the function implementation.
I did find a couple of Spark compatibility functions, such as spark hex and bitmap_count, that have dictionary paths and would likely need explicit opt-in if we align the default behavior.
From my side, the set of built-ins that may need changes looks bounded, so aligning the behavior seems manageable.
There was a problem hiding this comment.
Thanks for checking this; I think we can separate that work into a followup PR 👍
|
@Jefffrey All feedback has been resolved, please take another look when you have a chance |
Jefffrey
left a comment
There was a problem hiding this comment.
sorry i took so long to get back to this, ill try prioritize it now
| } | ||
|
|
||
| #[test] | ||
| fn test_coercible_dictionary_preserves_encoding() -> Result<()> { |
There was a problem hiding this comment.
Thanks for checking this; I'm a little worried because of the discrepancy here now; is it a big task to try align them? Though we might need to check the UDFs of existing functions that use a non-native typesignatureclass in case they already knew this assumption and had dictionary paths 🤔
6fc55ee to
adbdd13
Compare
Jefffrey
left a comment
There was a problem hiding this comment.
I think we just need to add a note to the upgrade guide; other than that, this PR looks good to me
| } | ||
|
|
||
| #[test] | ||
| fn test_coercible_dictionary_preserves_encoding() -> Result<()> { |
There was a problem hiding this comment.
Thanks for checking this; I think we can separate that work into a followup PR 👍
|
Thanks @Jefffrey , I added a note to the 55.0.0 upgrade guide for the new As a follow-up, I will look into the differing coercion behavior, and check which remaining scalar functions can safely opt in to dictionary preservation. That should help address #20935 more completely. |
Which issue does this PR close?
CoercionAPI #19458Rationale for this change
When a
Dictionary(K, Utf8)column is passed to a string scalar function, the type-coercion layer currently materializes it to flat Utf8/Utf8View before the function runs, so the operation is applied to every row instead of just the unique dictionary values, and the dictionary encoding is lost on the output. See #19458 for the underlying coercion behavior and #20935 for the string-function-specific impact.This is wasteful for low-cardinality columns and inflates Arrow IPC/Flight message sizes downstream.
What changes are included in this PR?
EncodingPreservation { None, Dictionary }and opt-in constructors onCoercion(new_exact_preserving_encoding,with_encoding_preservation).get_valid_types, when aCoerciblearg requestsDictionarypreservation, run coercion against the dictionary's value type and re-wrap the result asDictionary(K, V'), so the function receives aDictionaryArray.lower/upperopt in and handle dictionary inputs (array + scalar) by converting only the dictionary values and re-wrapping with the original keys.Are these changes tested?
Yes
Are there any user-facing changes?
Yes. upper/lower now return Dictionary(...) for dictionary inputs instead of the previously materialized Utf8View. New public API (EncodingPreservation, new Coercion constructors) is added — please add the api change label.