Is your feature request related to a problem or challenge?
While tracking down a collect_list / collect_set slowdown in Apache DataFusion Comet (comet#5797) I found two things in datafusion-spark / datafusion-functions-aggregate worth fixing upstream. Comet has worked around them locally (comet#5803) by supplying its own GroupsAccumulators, and would rather go back to the upstream implementations once these are addressed.
1. SparkCollectList / SparkCollectSet declare no GroupsAccumulator.
datafusion_spark::function::aggregate::collect implements only accumulator(), so grouped aggregation falls to GroupsAccumulatorAdapter: one boxed Accumulator per group, plus per-batch slicing and dispatch into each. This is the dominant cost at high grouping cardinality. Measured on 2,000,000 rows grouped by a string key with 200,000 distinct values, collect_list ran at 0.49x of Spark's own (JVM) implementation while the identical grouping with count(*) ran at 2.88x, i.e. the aggregate, not the group-by, was the problem.
ArrayAgg already has ArrayAggGroupsAccumulator, so SparkCollectList can largely reuse it. The one semantic difference is that Spark's collect_list returns [], not NULL, for a group whose inputs were all NULL, whereas ArrayAggGroupsAccumulator::evaluate marks such a group's list entry null. SparkCollectSet has no upstream grouped equivalent at all, since groups_accumulator_supported on ArrayAgg excludes the distinct case.
2. ArrayAggGroupsAccumulator::merge_batch is weak for low grouping cardinality.
merge_batch expands every state list into one (group_idx, row_idx) entry per element, and evaluate then gathers them with interleave. Merging partial states is exactly the case where each contribution is a long contiguous run, so a per-element gather is the wrong shape: interleave over N scattered indices is several times more expensive than copying the same N rows as a handful of contiguous slices.
Delegating Comet's grouped collect_list to array_agg_udaf().create_groups_accumulator(...) made a 64-group collect_list 15% slower end to end than the GroupsAccumulatorAdapter it replaced (the final stage alone was ~5x slower), while the high-cardinality shapes got 20-100x faster. So the adapter is currently the better choice for low-cardinality merges, which is worth fixing since merge_batch is where partial states always arrive.
3. DistinctArrayAggAccumulator is per-row in two places (already noted in the Comet issue, listed here for completeness): merge_batch walks the state ListArray row by row calling update_batch(&[val]) on one-element arrays, so every merged row pays a fresh RowConverter::append + create_hashes + probe setup; and evaluate round-trips every distinct element through ScalarValue::try_from_array and ScalarValue::new_list, which for struct elements is a full recursive ScalarValue::Struct materialisation per element.
Describe the solution you'd like
SparkCollectList: implement groups_accumulator_supported / create_groups_accumulator, reusing ArrayAggGroupsAccumulator with ignore_nulls = true and rewriting the lists it leaves null into empty lists.
SparkCollectSet: add a grouped distinct accumulator.
ArrayAggGroupsAccumulator: represent a contribution as a (group, start, len) range rather than one entry per row, coalescing consecutive same-group rows in update_batch and recording one range per list row in merge_batch. On emit, counting-sort the ranges into group order and pick the gather by average run length: concat of slices for long runs, interleave for scattered rows.
DistinctArrayAggAccumulator: encode the whole state batch once in merge_batch instead of per row, and decode with a single RowConverter::convert_rows in evaluate instead of per-element ScalarValue round trips.
Describe alternatives you've considered
Comet's implementations of (1)-(3) are in native/spark-expr/src/agg_funcs/collect.rs and could be moved upstream more or less as they are. They are Apache-2.0, in this project's style, and carry unit tests plus a criterion benchmark. CollectSetGroupsAccumulator there keeps the distinct values row-encoded in one arena, deduplicated on insert against an open-addressed index keyed by (group, encoded value), so a batch is encoded once for all of its groups.
Measurements from that PR, criterion, two-stage AggregateExec over 131,072 rows, versus the GroupsAccumulatorAdapter baseline:
| shape |
partial |
partial + final |
collect_list int64, 16k groups |
−97.9% |
−97.0% |
collect_list utf8, 16k groups |
−97.6% |
−96.7% |
collect_list utf8, 64 groups |
−18.0% |
−22.9% |
collect_list struct, 16k groups |
−99.2% |
−98.3% |
collect_set utf8, 16k groups |
−87.6% |
−84.8% |
collect_set utf8, 64 groups |
−59.8% |
−60.5% |
collect_set struct, 16k groups |
−92.9% |
−90.7% |
Additional context
Measured against DataFusion 55.0.0 with arrow-rs 59.2.0. Happy to open PRs for any of the four items.
Is your feature request related to a problem or challenge?
While tracking down a
collect_list/collect_setslowdown in Apache DataFusion Comet (comet#5797) I found two things indatafusion-spark/datafusion-functions-aggregateworth fixing upstream. Comet has worked around them locally (comet#5803) by supplying its ownGroupsAccumulators, and would rather go back to the upstream implementations once these are addressed.1.
SparkCollectList/SparkCollectSetdeclare noGroupsAccumulator.datafusion_spark::function::aggregate::collectimplements onlyaccumulator(), so grouped aggregation falls toGroupsAccumulatorAdapter: one boxedAccumulatorper group, plus per-batch slicing and dispatch into each. This is the dominant cost at high grouping cardinality. Measured on 2,000,000 rows grouped by a string key with 200,000 distinct values,collect_listran at 0.49x of Spark's own (JVM) implementation while the identical grouping withcount(*)ran at 2.88x, i.e. the aggregate, not the group-by, was the problem.ArrayAggalready hasArrayAggGroupsAccumulator, soSparkCollectListcan largely reuse it. The one semantic difference is that Spark'scollect_listreturns[], notNULL, for a group whose inputs were all NULL, whereasArrayAggGroupsAccumulator::evaluatemarks such a group's list entry null.SparkCollectSethas no upstream grouped equivalent at all, sincegroups_accumulator_supportedonArrayAggexcludes the distinct case.2.
ArrayAggGroupsAccumulator::merge_batchis weak for low grouping cardinality.merge_batchexpands every state list into one(group_idx, row_idx)entry per element, andevaluatethen gathers them withinterleave. Merging partial states is exactly the case where each contribution is a long contiguous run, so a per-element gather is the wrong shape:interleaveover N scattered indices is several times more expensive than copying the same N rows as a handful of contiguous slices.Delegating Comet's grouped
collect_listtoarray_agg_udaf().create_groups_accumulator(...)made a 64-groupcollect_list15% slower end to end than theGroupsAccumulatorAdapterit replaced (the final stage alone was ~5x slower), while the high-cardinality shapes got 20-100x faster. So the adapter is currently the better choice for low-cardinality merges, which is worth fixing sincemerge_batchis where partial states always arrive.3.
DistinctArrayAggAccumulatoris per-row in two places (already noted in the Comet issue, listed here for completeness):merge_batchwalks the stateListArrayrow by row callingupdate_batch(&[val])on one-element arrays, so every merged row pays a freshRowConverter::append+create_hashes+ probe setup; andevaluateround-trips every distinct element throughScalarValue::try_from_arrayandScalarValue::new_list, which for struct elements is a full recursiveScalarValue::Structmaterialisation per element.Describe the solution you'd like
SparkCollectList: implementgroups_accumulator_supported/create_groups_accumulator, reusingArrayAggGroupsAccumulatorwithignore_nulls = trueand rewriting the lists it leaves null into empty lists.SparkCollectSet: add a grouped distinct accumulator.ArrayAggGroupsAccumulator: represent a contribution as a(group, start, len)range rather than one entry per row, coalescing consecutive same-group rows inupdate_batchand recording one range per list row inmerge_batch. On emit, counting-sort the ranges into group order and pick the gather by average run length:concatof slices for long runs,interleavefor scattered rows.DistinctArrayAggAccumulator: encode the whole state batch once inmerge_batchinstead of per row, and decode with a singleRowConverter::convert_rowsinevaluateinstead of per-elementScalarValueround trips.Describe alternatives you've considered
Comet's implementations of (1)-(3) are in
native/spark-expr/src/agg_funcs/collect.rsand could be moved upstream more or less as they are. They are Apache-2.0, in this project's style, and carry unit tests plus a criterion benchmark.CollectSetGroupsAccumulatorthere keeps the distinct values row-encoded in one arena, deduplicated on insert against an open-addressed index keyed by(group, encoded value), so a batch is encoded once for all of its groups.Measurements from that PR, criterion, two-stage
AggregateExecover 131,072 rows, versus theGroupsAccumulatorAdapterbaseline:collect_listint64, 16k groupscollect_listutf8, 16k groupscollect_listutf8, 64 groupscollect_liststruct, 16k groupscollect_setutf8, 16k groupscollect_setutf8, 64 groupscollect_setstruct, 16k groupsAdditional context
Measured against DataFusion 55.0.0 with arrow-rs 59.2.0. Happy to open PRs for any of the four items.