perf: preserve dictionary encoding for lower/upper to avoid materializing low-cardinality columns - #22905
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. |
|
thanks @lyne7-sc |
## Which issue does this PR close? <!-- We generally require a GitHub issue to be filed for all bug fixes and enhancements and this helps us generate change logs for our releases. You can link an issue to this PR using the GitHub syntax. For example `Closes apache#123` indicates that this PR will close issue apache#123. --> - Follow-up to apache#22905. - Part of apache#19458 ## Rationale for this change <!-- Why are you proposing this change? If this is already explained clearly in the issue then this section is not needed. Explaining clearly why changes are proposed helps reviewers understand your changes and offer better suggestions for fixes. --> apache#22905 introduced explicit dictionary encoding preservation for coercible function signatures, but dictionary inputs were still handled differently across `TypeSignatureClass` variants: | Signature category | Before | After | | --- | --- | --- | | `Native(...)` | Materialized by default; preserved when explicitly requested | Same default/opt-in contract | | Typed non-Native (e.g. `Integer`, `Numeric`, `Binary`) | Retained the physical dictionary type by default | Materialized by default; preserved when explicitly requested | | `Any` | Passed through the original physical input type | Unchanged | This PR makes the encoding preservation contract consistent across all typed signature classes: coercion operates on the dictionary value type, and the dictionary encoding is restored only when `EncodingPreservation::dictionary()` is enabled. An audit of the affected built-ins found two functions, Spark hex and bitmap_count, that intentionally handle dictionary inputs; both now opt in explicitly. Other affected functions generally expect materialized value arrays, so the new default also avoids cases where signature matching accepted a dictionary but the function implementation rejected it at execution time. Functions that continue to materialize dictionary inputs do not gain dictionary-aware execution efficiency yet, but they can opt in later if they add support for encoded inputs. ## What changes are included in this PR? <!-- There is no need to duplicate the description in the issue here but it is sometimes worth providing a summary of the individual changes in this PR. --> - Align dictionary coercion across typed signature classes and preserve dictionary encoding when explicitly requested. - Explicitly enable dictionary preservation for Spark `bitmap_count` and the binary variant of Spark `hex`. - Document the behavior change and migration guidance in the DataFusion 55.0.0 upgrade guide. ## Are these changes tested? <!-- We typically require tests for all PRs in order to: 1. Prevent the code from being accidentally broken by subsequent changes 2. Serve as another way to document the expected behavior of the code If tests are not included in your PR, please explain why (for example, are they covered by existing tests)? --> - Unit tests cover materialization and preservation for Native, non-Native, and `Any` inputs. - SLTs cover `to_hex` materialization and verify that `bitmap_count` preserves its dictionary input without an additional cast to `Binary`. - Existing Spark `hex` dictionary tests cover its opt-in preservation behavior. ## Are there any user-facing changes? <!-- If there are user-facing changes then we may require documentation to be updated before approving the PR. --> <!-- If there are any breaking changes to public APIs, please add the `api change` label. --> This is a behavioral API change for UDFs using typed non-Native classes such as `Integer` or `Binary`. UDFs relying on implicit dictionary preservation must now enable `EncodingPreservation::dictionary()` explicitly. `TypeSignatureClass::Any` is unaffected. The upgrade guide has been updated, and this PR should carry the `api change` label.
## Which issue does this PR close? <!-- We generally require a GitHub issue to be filed for all bug fixes and enhancements and this helps us generate change logs for our releases. You can link an issue to this PR using the GitHub syntax. For example `Closes apache#123` indicates that this PR will close issue apache#123. --> - Follow-up to apache#22905. - Part of apache#19458 ## Rationale for this change <!-- Why are you proposing this change? If this is already explained clearly in the issue then this section is not needed. Explaining clearly why changes are proposed helps reviewers understand your changes and offer better suggestions for fixes. --> apache#22905 introduced explicit dictionary encoding preservation for coercible function signatures, but dictionary inputs were still handled differently across `TypeSignatureClass` variants: | Signature category | Before | After | | --- | --- | --- | | `Native(...)` | Materialized by default; preserved when explicitly requested | Same default/opt-in contract | | Typed non-Native (e.g. `Integer`, `Numeric`, `Binary`) | Retained the physical dictionary type by default | Materialized by default; preserved when explicitly requested | | `Any` | Passed through the original physical input type | Unchanged | This PR makes the encoding preservation contract consistent across all typed signature classes: coercion operates on the dictionary value type, and the dictionary encoding is restored only when `EncodingPreservation::dictionary()` is enabled. An audit of the affected built-ins found two functions, Spark hex and bitmap_count, that intentionally handle dictionary inputs; both now opt in explicitly. Other affected functions generally expect materialized value arrays, so the new default also avoids cases where signature matching accepted a dictionary but the function implementation rejected it at execution time. Functions that continue to materialize dictionary inputs do not gain dictionary-aware execution efficiency yet, but they can opt in later if they add support for encoded inputs. ## What changes are included in this PR? <!-- There is no need to duplicate the description in the issue here but it is sometimes worth providing a summary of the individual changes in this PR. --> - Align dictionary coercion across typed signature classes and preserve dictionary encoding when explicitly requested. - Explicitly enable dictionary preservation for Spark `bitmap_count` and the binary variant of Spark `hex`. - Document the behavior change and migration guidance in the DataFusion 55.0.0 upgrade guide. ## Are these changes tested? <!-- We typically require tests for all PRs in order to: 1. Prevent the code from being accidentally broken by subsequent changes 2. Serve as another way to document the expected behavior of the code If tests are not included in your PR, please explain why (for example, are they covered by existing tests)? --> - Unit tests cover materialization and preservation for Native, non-Native, and `Any` inputs. - SLTs cover `to_hex` materialization and verify that `bitmap_count` preserves its dictionary input without an additional cast to `Binary`. - Existing Spark `hex` dictionary tests cover its opt-in preservation behavior. ## Are there any user-facing changes? <!-- If there are user-facing changes then we may require documentation to be updated before approving the PR. --> <!-- If there are any breaking changes to public APIs, please add the `api change` label. --> This is a behavioral API change for UDFs using typed non-Native classes such as `Integer` or `Binary`. UDFs relying on implicit dictionary preservation must now enable `EncodingPreservation::dictionary()` explicitly. `TypeSignatureClass::Any` is unaffected. The upgrade guide has been updated, and this PR should carry the `api change` label.
…and `ascii` (apache#23743) ## Which issue does this PR close? <!-- We generally require a GitHub issue to be filed for all bug fixes and enhancements and this helps us generate change logs for our releases. You can link an issue to this PR using the GitHub syntax. For example `Closes apache#123` indicates that this PR will close issue apache#123. --> - Follow-up to apache#22905 - Related to apache#19458 - Related to apache#20935 ## Rationale for this change <!-- Why are you proposing this change? If this is already explained clearly in the issue then this section is not needed. Explaining clearly why changes are proposed helps reviewers understand your changes and offer better suggestions for fixes. --> String functions normally materialize `Dictionary(K, Utf8)` inputs before evaluation. This loses the dictionary encoding and applies the function to every row instead of only the unique dictionary values. This pr extends the dictionary-preserving implementation from apache#22905 to `ascii`, `bit_length`, and `octet_length`. ## What changes are included in this PR? <!-- There is no need to duplicate the description in the issue here but it is sometimes worth providing a summary of the individual changes in this PR. --> - Preserve dictionary encoding for `ascii`, `bit_length`, and `octet_length`. - Preserve return types for regular and nested dictionaries. - Add slts and Dictionary cardinality benchmarks. ## Are these changes tested? Yes, covered by SLTs. <!-- We typically require tests for all PRs in order to: 1. Prevent the code from being accidentally broken by subsequent changes 2. Serve as another way to document the expected behavior of the code If tests are not included in your PR, please explain why (for example, are they covered by existing tests)? --> ## Are there any user-facing changes? <!-- If there are user-facing changes then we may require documentation to be updated before approving the PR. --> <!-- If there are any breaking changes to public APIs, please add the `api change` label. --> Yes. These functions now preserve Dictionary encoding in their output: `Dictionary(K, Utf8) -> Dictionary(K, Int32)` ## Benchmarks ``` group main new ----- ---------------- --- dictionary_string_functions/cardinality_10/ascii 28.71 5.0±0.05µs ? ?/sec 1.00 172.5±12.31ns ? ?/sec dictionary_string_functions/cardinality_10/bit_length 4.01 698.7±25.75ns ? ?/sec 1.00 174.1±2.45ns ? ?/sec dictionary_string_functions/cardinality_10/octet_length 3.63 651.3±58.51ns ? ?/sec 1.00 179.4±3.27ns ? ?/sec dictionary_string_functions/cardinality_100/ascii 18.92 4.9±0.03µs ? ?/sec 1.00 258.6±8.28ns ? ?/sec dictionary_string_functions/cardinality_100/bit_length 3.11 723.4±27.14ns ? ?/sec 1.00 232.5±35.47ns ? ?/sec dictionary_string_functions/cardinality_100/octet_length 3.06 651.6±26.97ns ? ?/sec 1.00 213.2±6.96ns ? ?/sec dictionary_string_functions/cardinality_1000/ascii 6.32 4.8±0.03µs ? ?/sec 1.00 766.7±70.66ns ? ?/sec dictionary_string_functions/cardinality_1000/bit_length 3.06 776.1±67.85ns ? ?/sec 1.00 253.9±6.93ns ? ?/sec dictionary_string_functions/cardinality_1000/octet_length 3.10 807.9±188.65ns ? ?/sec 1.00 261.0±22.42ns ? ?/sec dictionary_string_functions/cardinality_8192/ascii 1.00 5.1±0.15µs ? ?/sec 1.00 5.1±0.24µs ? ?/sec dictionary_string_functions/cardinality_8192/bit_length 1.00 699.1±17.54ns ? ?/sec 1.09 763.7±36.57ns ? ?/sec dictionary_string_functions/cardinality_8192/octet_length 1.00 677.3±62.91ns ? ?/sec 1.11 750.7±32.56ns ? ?/sec ```
… and `reverse` (apache#23930) ## Which issue does this PR close? <!-- We generally require a GitHub issue to be filed for all bug fixes and enhancements and this helps us generate change logs for our releases. You can link an issue to this PR using the GitHub syntax. For example `Closes #123` indicates that this PR will close issue #123. --> - Follow-up to apache#22905 - Related to apache#19458 and apache#20935 ## Rationale for this change <!-- Why are you proposing this change? If this is already explained clearly in the issue then this section is not needed. Explaining clearly why changes are proposed helps reviewers understand your changes and offer better suggestions for fixes. --> Previously, coercion materialized dictionary-encoded inputs for `character_length`, `initcap`, and `reverse`. This lost the encoding and evaluated the function for every row instead of once per dictionary value entry. This PR extends dictionary preservation to these functions. `character_length` and `reverse` now use `Coercible` signatures to explicitly model string inputs and binary-to-string coercion while preserving dictionary encoding. ## What changes are included in this PR? <!-- There is no need to duplicate the description in the issue here but it is sometimes worth providing a summary of the individual changes in this PR. --> - Preserve dictionary encoding for `character_length`, `initcap`, and `reverse`. - Evaluate only dictionary values while reusing the original keys. - Migrate `character_length` and `reverse` from `Uniform` to `Coercible`. - Add tests and benchmarks. ## Are these changes tested? <!-- We typically require tests for all PRs in order to: 1. Prevent the code from being accidentally broken by subsequent changes 2. Serve as another way to document the expected behavior of the code If tests are not included in your PR, please explain why (for example, are they covered by existing tests)? --> Yes, covered by SQL logic tests. ## Are there any user-facing changes? <!-- If there are user-facing changes then we may require documentation to be updated before approving the PR. --> <!-- If there are any breaking changes to public APIs, please add the `api change` label. --> Yes. These functions now preserve dictionary encoding in their output. `character_length` and `reverse` now accept logical string and binary inputs instead of implicitly converting unrelated types to strings. ## Benchmark ``` group branch main ----- ------ ---- dictionary_encoding/string/cardinality_10/character_length 1.00 218.5±32.62ns ? ?/sec 31.04 6.8±0.07µs ? ?/sec dictionary_encoding/string/cardinality_10/initcap 1.00 292.2±4.75ns ? ?/sec 359.88 105.2±0.91µs ? ?/sec dictionary_encoding/string/cardinality_10/reverse 1.00 501.6±15.71ns ? ?/sec 140.60 70.5±1.05µs ? ?/sec dictionary_encoding/string/cardinality_100/character_length 1.00 307.5±24.93ns ? ?/sec 21.85 6.7±0.09µs ? ?/sec dictionary_encoding/string/cardinality_100/initcap 1.00 1486.1±65.36ns ? ?/sec 70.56 104.9±1.57µs ? ?/sec dictionary_encoding/string/cardinality_100/reverse 1.00 1410.9±52.69ns ? ?/sec 49.28 69.5±0.68µs ? ?/sec dictionary_encoding/string/cardinality_1000/character_length 1.00 1007.4±93.42ns ? ?/sec 6.64 6.7±0.07µs ? ?/sec dictionary_encoding/string/cardinality_1000/initcap 1.00 12.8±0.17µs ? ?/sec 8.41 107.5±8.55µs ? ?/sec dictionary_encoding/string/cardinality_1000/reverse 1.00 10.5±0.24µs ? ?/sec 6.56 69.1±0.58µs ? ?/sec dictionary_encoding/string/cardinality_8192/character_length 1.00 6.6±0.09µs ? ?/sec 1.02 6.7±0.18µs ? ?/sec dictionary_encoding/string/cardinality_8192/initcap 1.00 104.2±1.36µs ? ?/sec 1.00 104.2±1.63µs ? ?/sec dictionary_encoding/string/cardinality_8192/reverse 1.24 85.4±2.99µs ? ?/sec 1.00 69.0±0.58µs ? ?/sec ``` --------- Co-authored-by: Jeffrey Vo <jeffrey.vo.australia@gmail.com>
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.