feat(physical-plan): generic Rows-backed GroupColumn keeps mixed schemas on the column-wise path#23523
feat(physical-plan): generic Rows-backed GroupColumn keeps mixed schemas on the column-wise path#23523zhuqi-lucas wants to merge 1 commit into
Conversation
…d schemas on the column-wise path Add `RowsGroupColumn`, a generic `GroupColumn` backed by a single-field `RowConverter` from arrow's row format. Route nested types (`Struct`, `List`, `LargeList`, `FixedSizeList`, and recursive combinations arrow's row converter can encode) to it via `group_column_supported_type` / `make_group_column`. Before this, a single nested column in a GROUP BY key made `supported_schema` return `false` and the whole aggregation fell back to the row-wise `GroupValuesRows`, even when every other column would have qualified for the column-wise fast path. With the generic fallback, only the nested column pays the row-encoding cost; the native columns keep their compact column-wise storage. Impact (measured by `mixed_schema_column_path_uses_less_memory_than_rows_fallback` with 4000 groups of `8 × Int64 + 1 × FixedSizeList<Int64, 4>`): - old (all-rows fallback): 1096 KB - new (column-wise + row-backed nested column): **594 KB (54.2%)** Correctness: - Hashing (`create_hashes` on the raw input columns) already supports nested types, and the caller-side `-0.0/NaN` normalization performed for `GroupValuesRows` keeps hashing aligned with the row-format equality this column implements. Covered by `nested_float_edge_cases_match_rows_fallback`. - Multi-batch streaming intern + `EmitTo::First` / `take_n` covered by `multi_batch_and_emit_first_matches_rows_fallback`. - FSL / Struct roundtrip + `supports_type` invariant covered by the unit tests inside `row_backed`. Follow-ups tracked separately: - Cover types `RowConverter` cannot encode (currently a moving target; if the arrow-rs release DataFusion pins encodes them all — including Map — the fallback is complete). - With full coverage, retire `GroupValuesRows` entirely (motivation: apache#23404). Refs: apache#23404, apache#22715.
There was a problem hiding this comment.
Pull request overview
This PR improves GroupValuesColumn mixed-schema GROUP BY handling by adding a generic row-format-backed GroupColumn for nested types, so only unsupported (nested) columns pay row-encoding overhead instead of forcing the entire key onto the GroupValuesRows fallback.
Changes:
- Introduces
RowsGroupColumn, a genericGroupColumnbacked by Arrow’s row format for nested (row-encodable) types. - Updates the
group_column_supported_type/make_group_columndispatch so nested types useRowsGroupColumnwhile intentionally excluded scalar types remain onGroupValuesRows. - Adds unit and integration-style tests validating correctness (including float edge cases) and reduced memory usage for mixed schemas.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| datafusion/physical-plan/src/aggregates/group_values/row.rs | Documents and exposes dictionary_encode_if_necessary for reuse by the new row-backed column path. |
| datafusion/physical-plan/src/aggregates/group_values/multi_group_by/row_backed.rs | Adds RowsGroupColumn implementation and unit tests for nested types via Arrow row encoding. |
| datafusion/physical-plan/src/aggregates/group_values/multi_group_by/mod.rs | Wires nested-type fallback into supported-type checks and factory dispatch; adds mixed-schema memory/correctness tests. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| fn rows_to_array<'a>( | ||
| &self, | ||
| rows: impl IntoIterator<Item = arrow::row::Row<'a>>, | ||
| ) -> ArrayRef { | ||
| let mut arrays = self | ||
| .row_converter | ||
| .convert_rows(rows) | ||
| .expect("row conversion during emit"); | ||
| debug_assert_eq!(arrays.len(), 1, "single-field row converter"); | ||
| let array = arrays.swap_remove(0); | ||
| dictionary_encode_if_necessary(&array, &self.output_type) | ||
| .expect("dictionary re-encode during emit") | ||
| } |
|
nice! excited to take a look at this |
I think speed is an important piece of this PR — could we at least run the existing benchmarks? IIRC the TPC-H/ClickBench/etc benchmarks don't actually include certain data types, such as dictionaries and FixedSizeList, so they may not be the best indicator of performance. But they should at least give us a ballpark sense of whether this causes a regression. |
Rich-T-kid
left a comment
There was a problem hiding this comment.
I took a first pass through, mostly looks good. I left some comments. would be happy to take another look.
| /// Re-apply dictionary / run-end encoding to `array` so it matches `expected`. | ||
| /// | ||
| /// Arrow's [`RowConverter`] decodes dictionary and run-end-encoded values to | ||
| /// their plain value type on the way out, so any group-value array produced |
There was a problem hiding this comment.
Arrow's [
RowConverter] decodes dictionary and run-end-encoded values to
their plain value type on the way out
nit: Id say RowConverter decodes dictionarys/REE on their way in since it occurs onRowConverter::append().
| /// being returned. Shared with the generic row-backed `GroupColumn`. | ||
| /// | ||
| /// [`RowConverter`]: arrow::row::RowConverter | ||
| pub(crate) fn dictionary_encode_if_necessary( |
There was a problem hiding this comment.
the comment above mentions casting arrays to REE but the functions is called Dictionary_encode_if_necessary.
| pub(crate) fn dictionary_encode_if_necessary( | |
| pub(crate) fn encode_array_if_necessary( |
or something of the sort
| /// their plain value type on the way out, so any group-value array produced | ||
| /// from the row format must be re-encoded to the schema's expected type before | ||
| /// being returned. Shared with the generic row-backed `GroupColumn`. |
There was a problem hiding this comment.
I think the cast here shows that Dictionaries and REE arrays should have their own specialized implementation of GroupColumn. For now I think its fine but unlike other implementations like fixedSizeList that can go from
arrow-array -> RowFormat -> arrow-array
dictionary/REE need an extra cast step
arrow-array -> rowFormat -> arrow-array -> (cast) -> arrow-array
| // encode them. Gate the fallback to nested types so intentionally-excluded | ||
| // scalar types (e.g. Float16, Decimal256) stay on `GroupValuesRows` and the | ||
| // `group_column_supported_type` ⇔ `make_group_column` invariant holds. | ||
| if data_type.is_nested() { | ||
| return RowsGroupColumn::supports_type(data_type); | ||
| } |
There was a problem hiding this comment.
why are Float16 & Decimal256 arrays not supported for this optimization?
| assert!( | ||
| column_size < rows_size, | ||
| "expected column-wise path ({column_size}) to use less memory than \ | ||
| the all-rows fallback ({rows_size})" | ||
| ); |
There was a problem hiding this comment.
this is a nice way to guarantee groupColumns will always consume less memory than the GroupValueRows implementation!
| /// Relabel a group-index vector so labels are assigned in order of first | ||
| /// appearance. Two vectors are equivalent groupings iff their canonical | ||
| /// forms are equal — this ignores the (opaque, non-semantic) difference in | ||
| /// group-index numbering between the vectorized column path and the | ||
| /// sequential rows fallback. | ||
| fn canonical_grouping(groups: &[usize]) -> Vec<usize> { | ||
| let mut map = HashMap::new(); | ||
| let mut next = 0usize; | ||
| groups | ||
| .iter() | ||
| .map(|&g| { | ||
| *map.entry(g).or_insert_with(|| { | ||
| let v = next; | ||
| next += 1; | ||
| v | ||
| }) | ||
| }) | ||
| .collect() | ||
| } |
There was a problem hiding this comment.
This took me a bit of time to understand why its needed. from my understanding its because GroupValues::intern() doesn't specify the order in which new IDs are given. docs
I think the comment is fine as it is but maybe adding something like
GroupValuesimplementations only guarantee that equal rows receive
/// equal group ids and new rows receive a fresh id — the order in which
/// new ids are handed out is not part of the contract, and can differ
/// between correct implementations
can help readers know why this function has to exist. This is mostly a nit
| //! and only pays the row-encoding cost on `struct_col`, instead of dragging the | ||
| //! entire key onto `GroupValuesRows`. |
There was a problem hiding this comment.
nit:
| //! and only pays the row-encoding cost on `struct_col`, instead of dragging the | |
| //! entire key onto `GroupValuesRows`. | |
| //! and only pays the row-encoding cost on `struct_col`, instead of dragging both | |
| //! columns onto `GroupValuesRows`. |
| let mut arrays = self | ||
| .row_converter | ||
| .convert_rows(rows) | ||
| .expect("row conversion during emit"); |
There was a problem hiding this comment.
You may be interested in these issues/ PR in arrow-rs
| } | ||
|
|
||
| fn take_n(&mut self, n: usize) -> ArrayRef { | ||
| debug_assert!(n <= self.group_values.num_rows()); |
There was a problem hiding this comment.
does DataFusion guarantee that it wont call GroupValues::emit(emit::to(n)) where n is possibly greater than the number of groups?
Which issue does this PR close?
GroupValuesColumnEPIC)GroupValuesRowsentirely (Generic Rows-backed GroupColumn so nested GROUP BY keys stay on the column-wise fast path #23404)Rationale for this change
Today
GroupValuesColumnis all-or-nothing: a single nested column in the GROUP BY key (`Struct`, `List`, `FixedSizeList`, …) makes `supported_schema` return `false` and drops the entire aggregation onto the row-wise `GroupValuesRows` fallback — even when every other column would have qualified for the column-wise fast path. For a `GROUP BY int_col, struct_col` shape, the `int_col` pays the row-encoded storage cost for no reason.What changes are included in this PR?
Add `RowsGroupColumn`: a generic `GroupColumn` backed by a single-field `RowConverter`, wired in as the nested-type dispatch arm of `group_column_supported_type` / `make_group_column`. Native columns keep their type-specialized builders; the nested column pays row-encoding only for its one column.
Gated to `data_type.is_nested()` so intentionally excluded scalar types (Float16, Decimal256) stay on `GroupValuesRows` and the `group_column_supported_type` ⇔ `make_group_column` invariant holds.
Impact
Memory, measured with 4000 groups of `8 × Int64 + 1 × FixedSizeList<Int64, 4>` in `mixed_schema_column_path_uses_less_memory_than_rows_fallback`:
Speed: not benchmarked as a headline result — the wins come from native columns keeping their type-specialized `equal_to`/`append_val` fast paths instead of falling back to byte-encoded row comparisons.
Are these changes tested?
Yes:
All 39 tests in `aggregates::group_values` pass.
Are there any user-facing changes?
No — internal aggregation representation only. Same query results, lower memory footprint on mixed-schema GROUP BY keys.
Follow-ups (out of scope)