This is an automated email from the ASF dual-hosted git repository.

andygrove pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-comet.git


The following commit(s) were added to refs/heads/main by this push:
     new 5c5dda8374 perf: give collect_list and collect_set a native 
GroupsAccumulator (#5803)
5c5dda8374 is described below

commit 5c5dda8374d0b8b93b877281ed4b5fdca1eed324
Author: Andy Grove <[email protected]>
AuthorDate: Thu Sep 10 21:50:36 2026 -0600

    perf: give collect_list and collect_set a native GroupsAccumulator (#5803)
---
 .../expression-audits/agg_funcs.md                 |    6 +-
 native/core/src/execution/planner.rs               |   54 +-
 native/spark-expr/Cargo.toml                       |    4 +
 native/spark-expr/benches/collect.rs               |  258 ++++
 native/spark-expr/src/agg_funcs/collect.rs         | 1255 ++++++++++++++++++++
 native/spark-expr/src/agg_funcs/mod.rs             |    2 +
 .../apache/comet/exec/CometAggregateSuite.scala    |   23 +
 7 files changed, 1584 insertions(+), 18 deletions(-)

diff --git a/docs/source/contributor-guide/expression-audits/agg_funcs.md 
b/docs/source/contributor-guide/expression-audits/agg_funcs.md
index b2d2efae6b..8d0ff7be68 100644
--- a/docs/source/contributor-guide/expression-audits/agg_funcs.md
+++ b/docs/source/contributor-guide/expression-audits/agg_funcs.md
@@ -57,8 +57,9 @@
 - Spark 3.5.8 (audited 2026-06-24): identical to 3.4.3.
 - Spark 4.0.1 (audited 2026-06-24): only structural change is adding `with 
UnaryLike[Expression]` to the case class (no behavior change).
 - Spark 4.1.1 (audited 2026-06-24): identical to 4.0.1.
-- Comet implementation: native side delegates to 
`datafusion_spark::function::aggregate::collect::SparkCollectList`, which wraps 
`ArrayAggAccumulator` with `ignore_nulls = true` and converts a final NULL 
accumulator state to an empty array (matching Spark's `defaultResult`). The 
native return type is `List(Field, containsNull = true)`, while Spark uses 
`containsNull = false`. Because nulls are filtered before insertion, no nulls 
actually appear in the array, so this is a schema-shape dif [...]
+- Comet implementation: `CometCollectList` 
(`native/spark-expr/src/agg_funcs/collect.rs`) delegates the ungrouped path to 
`datafusion_spark::function::aggregate::collect::SparkCollectList`, which wraps 
`ArrayAggAccumulator` with `ignore_nulls = true` and converts a final NULL 
accumulator state to an empty array (matching Spark's `defaultResult`); grouped 
aggregation uses Comet's own `GroupsAccumulator`, which retains the input 
arrays and gathers them into group order on emit. The native  [...]
 - Buffer shape (applies equally to `collect_set`): both are 
`TypedImperativeAggregate`s, so Spark's `aggBufferAttributes` declares the 
intermediate buffer as `BinaryType` (serialized state) while the native 
accumulator's `state_fields` is a `List`. 
`CometBaseAggregate.adjustOutputForNativeState` rewrites the Comet-side Partial 
output to the list shape. Neither collector can split a Partial and Final 
across Comet and Spark, and a multi-stage distinct rewrite (which inserts a 
`PartialMerge [...]
+- Performance (tuned 2026-09-09, PR 
[#5803](https://github.com/apache/datafusion-comet/pull/5803)): grouped 
`collect_list` used to run through DataFusion's `GroupsAccumulatorAdapter`, 
which keeps one boxed `Accumulator` per group and slices every batch into a 
per-group `update_batch` call. `CollectListGroupsAccumulator` instead records a 
`(group, row range)` contribution per batch and rearranges them with a counting 
sort on emit, gathering long runs with `concat` and scattered rows with  [...]
 - Spark 4.2 (preview): `CollectList` and `CollectSet` gain an `ignoreNulls` 
field (default `true`); `RESPECT NULLS` sets it to `false` and keeps null 
elements. The native path always drops nulls, so `CometCollectShim` reads the 
field per Spark version (always `true` on 3.4-4.1) and `CometCollectList` / 
`CometCollectSet` report `Unsupported` when it is `false`, falling back to 
Spark.
 
 ## collect_set
@@ -67,7 +68,8 @@
 - Spark 3.5.8 (audited 2026-07-27): identical to 3.4.3.
 - Spark 4.0.1 (audited 2026-07-27): adds `with UnaryLike[Expression]` to the 
case class, and `checkInputDataTypes` additionally requires 
`UnsafeRowUtils.isBinaryStable(child.dataType)`, so non-default-collation 
strings are rejected along with maps. Deduplication semantics unchanged.
 - Spark 4.1.1 (audited 2026-07-27): identical to 4.0.1.
-- Comet implementation: the native side delegates to 
`datafusion_spark::function::aggregate::collect::SparkCollectSet`, which wraps 
`DistinctArrayAggAccumulator` with `ignore_nulls = true` in a 
`NullToEmptyListAccumulator` so a final NULL accumulator state becomes an empty 
array. The `containsNull` mismatch against Spark's declared output type, and 
its rationale, are identical to [collect_list](#collect-list).
+- Comet implementation: `CometCollectSet` 
(`native/spark-expr/src/agg_funcs/collect.rs`) delegates the ungrouped path to 
`datafusion_spark::function::aggregate::collect::SparkCollectSet`, which wraps 
`DistinctArrayAggAccumulator` with `ignore_nulls = true` in a 
`NullToEmptyListAccumulator` so a final NULL accumulator state becomes an empty 
array; grouped aggregation uses Comet's own `GroupsAccumulator`, which keeps 
the distinct values row-encoded in one arena keyed by `(group, value)`. D [...]
+- Performance (tuned 2026-09-09, PR 
[#5803](https://github.com/apache/datafusion-comet/pull/5803)): as for 
[collect_list](#collect-list), grouped `collect_set` no longer goes through 
`GroupsAccumulatorAdapter`. `CollectSetGroupsAccumulator` encodes each batch 
once for all of its groups and deduplicates against an open-addressed index 
over a shared arena, replacing one hash table plus one owned `Row` per distinct 
value per group. 60-93% faster ([#5797](https://github.com/apache/datafusion 
[...]
 - `CometCollectSet` reports `Incompatible` for float and double input when 
`spark.comet.exec.strictFloatingPoint=true`, because the native distinct 
comparison treats `NaN == NaN` and collapses repeated `NaN`s into a single 
element while Spark keeps each one. The native path for floating-point input is 
then opt-in via `spark.comet.expression.CollectSet.allowIncompatible=true`. All 
other input types are `Compatible`.
 
 ## max_by
diff --git a/native/core/src/execution/planner.rs 
b/native/core/src/execution/planner.rs
index eeaee34478..d66638ba32 100644
--- a/native/core/src/execution/planner.rs
+++ b/native/core/src/execution/planner.rs
@@ -90,10 +90,9 @@ use datafusion::{
 };
 use datafusion_comet_spark_expr::{
     create_comet_physical_fun, create_comet_physical_fun_with_eval_mode, 
BinaryOutputStyle,
-    BloomFilterAgg, BloomFilterMightContain, CsvWriteOptions, EvalMode, 
SparkArraysZipFunc,
-    SparkBloomFilterVersion, SparkPercentile, SumInteger, ToCsv,
+    BloomFilterAgg, BloomFilterMightContain, CometCollectList, 
CometCollectSet, CsvWriteOptions,
+    EvalMode, SparkArraysZipFunc, SparkBloomFilterVersion, SparkPercentile, 
SumInteger, ToCsv,
 };
-use datafusion_spark::function::aggregate::collect::{SparkCollectList, 
SparkCollectSet};
 use iceberg::expr::Bind;
 
 use crate::execution::operators::ExecutionError::GeneralError;
@@ -3174,13 +3173,13 @@ impl PhysicalPlanner {
             AggExprStruct::CollectSet(expr) => {
                 let child = self.create_expr(expr.child.as_ref().unwrap(), 
Arc::clone(&schema))?;
                 let child = Self::coerce_collect_child_nullability(child, 
&schema)?;
-                let func = AggregateUDF::new_from_impl(SparkCollectSet::new());
+                let func = AggregateUDF::new_from_impl(CometCollectSet::new());
                 Self::create_aggr_func_expr("collect_set", schema, 
vec![child], func)
             }
             AggExprStruct::CollectList(expr) => {
                 let child = self.create_expr(expr.child.as_ref().unwrap(), 
Arc::clone(&schema))?;
                 let child = Self::coerce_collect_child_nullability(child, 
&schema)?;
-                let func = 
AggregateUDF::new_from_impl(SparkCollectList::new());
+                let func = 
AggregateUDF::new_from_impl(CometCollectList::new());
                 Self::create_aggr_func_expr("collect_list", schema, 
vec![child], func)
             }
             AggExprStruct::Hllpp(expr) => {
@@ -3841,6 +3840,9 @@ impl PhysicalPlanner {
     /// schema types") — `RecordBatch::try_new` compares with 
`DataType::equals_datatype`, which
     /// does compare nested nullability.
     ///
+    /// The grouped path normalizes its own inputs, so this only still matters 
for the ungrouped
+    /// `Accumulator` (a global aggregate, or a window frame).
+    ///
     /// Casting unconditionally (rather than only when the declared type has a 
non-nullable
     /// nested field) makes this a normalization barrier: the accumulator is 
guaranteed to see
     /// arrays of exactly the type the plan declared, whichever direction the 
drift goes. The
@@ -5082,8 +5084,8 @@ mod tests {
         FileGroup, FileScanConfigBuilder, FileSource, ParquetSource,
     };
     use datafusion::error::DataFusionError;
-    use datafusion::logical_expr::AggregateUDF;
     use datafusion::logical_expr::ScalarUDF;
+    use datafusion::logical_expr::{AggregateUDF, EmitTo};
     use datafusion::physical_expr::expressions::Column;
     use datafusion::physical_expr::{LexOrdering, PhysicalExpr, 
PhysicalSortExpr};
     use datafusion::physical_plan::sorts::sort::SortExec;
@@ -5091,8 +5093,8 @@ mod tests {
     use datafusion::physical_plan::ExecutionPlan;
     use datafusion::prelude::SessionConfig;
     use datafusion::{assert_batches_eq, physical_plan::common::collect, 
prelude::SessionContext};
+    use datafusion_comet_spark_expr::{CometCollectList, CometCollectSet};
     use datafusion_physical_expr_adapter::PhysicalExprAdapterFactory;
-    use datafusion_spark::function::aggregate::collect::{SparkCollectList, 
SparkCollectSet};
     use parquet::variant::VariantType;
     use tempfile::TempDir;
     use tokio::sync::mpsc;
@@ -7284,15 +7286,16 @@ mod tests {
     }
 
     /// Builds `func` over `child` against `plan_schema`, feeds it a batch 
built from
-    /// `batch_schema`, and returns (type declared by `state_fields()`, type 
of the emitted
-    /// state array). This is the pair `RecordBatch::try_new` compares inside
+    /// `batch_schema`, and returns (type declared by `state_fields()`, type 
of the state the
+    /// ungrouped `Accumulator` emits, type of the state the 
`GroupsAccumulator` emits). The
+    /// declared type is compared against the emitted one by 
`RecordBatch::try_new` inside
     /// `GroupedHashAggregateStream::emit`.
     fn collect_agg_declared_vs_produced(
         child: &Arc<dyn PhysicalExpr>,
         plan_schema: &SchemaRef,
         batch_schema: &SchemaRef,
         func: AggregateUDF,
-    ) -> (DataType, DataType) {
+    ) -> (DataType, DataType, DataType) {
         let agg = PhysicalPlanner::create_aggr_func_expr(
             "collect",
             Arc::clone(plan_schema),
@@ -7311,8 +7314,14 @@ mod tests {
             .into_array(batch.num_rows())
             .unwrap();
         let mut acc = agg.create_accumulator().unwrap();
-        acc.update_batch(&[arg]).unwrap();
-        (declared, acc.state().unwrap()[0].data_type())
+        acc.update_batch(&[Arc::clone(&arg)]).unwrap();
+        let produced = acc.state().unwrap()[0].data_type();
+
+        let mut groups = agg.create_groups_accumulator().unwrap();
+        groups.update_batch(&[arg], &[0, 0], None, 1).unwrap();
+        let grouped = 
groups.state(EmitTo::All).unwrap()[0].data_type().clone();
+
+        (declared, produced, grouped)
     }
 
     /// `collect_list` / `collect_set` derive their accumulator state type 
from the declared
@@ -7325,6 +7334,10 @@ mod tests {
     /// rebuilds its state list honouring `data_type`. `collect_list` still 
rebuilds from the
     /// runtime array and drifts, so the coercion remains necessary. This test 
pins that
     /// `collect_list` still drifts without the coercion, and that the 
coercion normalizes both.
+    ///
+    /// The grouped path never drifts: `CollectListGroupsAccumulator` /
+    /// `CollectSetGroupsAccumulator` normalize every array they take in to 
the declared element
+    /// type, so their state matches the declaration with or without the 
coercion.
     #[test]
     fn test_collect_agg_absorbs_nested_nullability_drift() {
         let plan_schema = collect_agg_schema(collect_agg_struct_type(true));
@@ -7335,13 +7348,13 @@ mod tests {
                 .unwrap();
 
         for func in [
-            AggregateUDF::new_from_impl(SparkCollectSet::new()),
-            AggregateUDF::new_from_impl(SparkCollectList::new()),
+            AggregateUDF::new_from_impl(CometCollectSet::new()),
+            AggregateUDF::new_from_impl(CometCollectList::new()),
         ] {
             // Without the coercion, collect_list still drifts (declared 
nullable leaves vs
             // produced non-null ones). collect_set no longer drifts after 
DataFusion 55's new_list
             // fix, so only assert the drift for the func that still exhibits 
it.
-            let (declared, produced) =
+            let (declared, produced, grouped) =
                 collect_agg_declared_vs_produced(&raw, &plan_schema, 
&batch_schema, func.clone());
             if func.name() == "collect_list" {
                 assert!(
@@ -7350,15 +7363,24 @@ mod tests {
                     func.name()
                 );
             }
+            assert!(
+                declared.equals_datatype(&grouped),
+                "grouped state drifted for {}: declared {declared}, produced 
{grouped}",
+                func.name()
+            );
 
             // With the coercion, the argument is normalized to the declared 
type before it
             // reaches the accumulator, so both funcs emit a state matching 
the declared type.
-            let (declared, produced) =
+            let (declared, produced, grouped) =
                 collect_agg_declared_vs_produced(&coerced, &plan_schema, 
&batch_schema, func);
             assert!(
                 declared.equals_datatype(&produced),
                 "declared {declared} != produced {produced}"
             );
+            assert!(
+                declared.equals_datatype(&grouped),
+                "declared {declared} != grouped {grouped}"
+            );
         }
     }
 
diff --git a/native/spark-expr/Cargo.toml b/native/spark-expr/Cargo.toml
index 98e5a99904..987d2f5935 100644
--- a/native/spark-expr/Cargo.toml
+++ b/native/spark-expr/Cargo.toml
@@ -163,6 +163,10 @@ harness = false
 name = "bloom_filter_agg"
 harness = false
 
+[[bench]]
+name = "collect"
+harness = false
+
 [[bench]]
 name = "padding"
 harness = false
diff --git a/native/spark-expr/benches/collect.rs 
b/native/spark-expr/benches/collect.rs
new file mode 100644
index 0000000000..67bfa54b94
--- /dev/null
+++ b/native/spark-expr/benches/collect.rs
@@ -0,0 +1,258 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Grouped `collect_list` / `collect_set` aggregation over a high-cardinality 
string key,
+//! the shape reported in 
<https://github.com/apache/datafusion-comet/issues/5797>.
+
+use arrow::array::builder::{Int64Builder, StringBuilder};
+use arrow::array::{ArrayRef, RecordBatch, StructArray};
+use arrow::datatypes::{DataType, Field, Fields, Schema};
+use criterion::{criterion_group, criterion_main, Criterion};
+use datafusion::execution::TaskContext;
+use datafusion::logical_expr::AggregateUDF;
+use datafusion::physical_expr::aggregate::AggregateExprBuilder;
+use datafusion::physical_expr::expressions::Column;
+use datafusion::physical_expr::PhysicalExpr;
+use datafusion::physical_plan::aggregates::{AggregateExec, AggregateMode, 
PhysicalGroupBy};
+use datafusion::physical_plan::ExecutionPlan;
+use datafusion::{
+    datasource::memory::MemorySourceConfig, datasource::source::DataSourceExec,
+    physical_plan::collect,
+};
+use datafusion_comet_spark_expr::{CometCollectList, CometCollectSet};
+use std::sync::Arc;
+use std::time::Duration;
+use tokio::runtime::Runtime;
+
+const BATCH_SIZE: usize = 8192;
+const NUM_BATCHES: usize = 16;
+
+/// Element column under aggregation.
+#[derive(Clone, Copy)]
+enum Element {
+    Int64,
+    Utf8,
+    Struct,
+}
+
+fn criterion_benchmark(c: &mut Criterion) {
+    let rt = Runtime::new().unwrap();
+    let mut group = c.benchmark_group("collect");
+
+    // (label, element type, distinct group keys, distinct element values, 
null ratio, keys
+    // clustered into runs rather than cycling)
+    let shapes = [
+        ("int64/high_card", Element::Int64, 16384, 1 << 20, 0, false),
+        ("utf8/high_card", Element::Utf8, 16384, 1 << 20, 0, false),
+        ("utf8/low_card", Element::Utf8, 64, 1 << 20, 0, false),
+        ("utf8/clustered", Element::Utf8, 64, 1 << 20, 0, true),
+        ("utf8/dup_elements", Element::Utf8, 16384, 64, 0, false),
+        ("utf8/nulls", Element::Utf8, 16384, 1 << 20, 3, false),
+        (
+            "struct/high_card",
+            Element::Struct,
+            16384,
+            1 << 20,
+            0,
+            false,
+        ),
+    ];
+
+    for (label, element, num_groups, num_values, null_every, clustered) in 
shapes {
+        let partitions = vec![build_batches(
+            element, num_groups, num_values, null_every, clustered,
+        )];
+        for (fn_name, udf) in [
+            (
+                "collect_list",
+                Arc::new(AggregateUDF::new_from_impl(CometCollectList::new())),
+            ),
+            (
+                "collect_set",
+                Arc::new(AggregateUDF::new_from_impl(CometCollectSet::new())),
+            ),
+        ] {
+            for two_stage in [false, true] {
+                let mode = if two_stage { "two_stage" } else { "partial" };
+                // Build the plan once: only execution should be measured.
+                let plan = aggregate_plan(&partitions, Arc::clone(&udf), 
two_stage);
+                group.bench_function(format!("{fn_name}/{label}/{mode}"), |b| {
+                    b.iter(|| {
+                        rt.block_on(async {
+                            let batches =
+                                collect(Arc::clone(&plan), 
Arc::new(TaskContext::default()))
+                                    .await
+                                    .unwrap();
+                            assert!(!batches.is_empty());
+                        })
+                    })
+                });
+            }
+        }
+    }
+
+    group.finish();
+}
+
+fn aggregate_plan(
+    partitions: &[Vec<RecordBatch>],
+    udf: Arc<AggregateUDF>,
+    two_stage: bool,
+) -> Arc<dyn ExecutionPlan> {
+    let schema = &partitions[0][0].schema();
+    let scan: Arc<dyn ExecutionPlan> = Arc::new(DataSourceExec::new(Arc::new(
+        MemorySourceConfig::try_new(partitions, Arc::clone(schema), 
None).unwrap(),
+    )));
+    let key: Arc<dyn PhysicalExpr> = Arc::new(Column::new("key", 0));
+    let value: Arc<dyn PhysicalExpr> = Arc::new(Column::new("value", 1));
+    let aggr_expr = Arc::new(
+        AggregateExprBuilder::new(udf, vec![value])
+            .schema(Arc::clone(schema))
+            .alias("agg")
+            .build()
+            .unwrap(),
+    );
+
+    let partial: Arc<dyn ExecutionPlan> = Arc::new(
+        AggregateExec::try_new(
+            AggregateMode::Partial,
+            PhysicalGroupBy::new_single(vec![(Arc::clone(&key), 
"key".to_string())]),
+            vec![Arc::clone(&aggr_expr)],
+            vec![None],
+            scan,
+            Arc::clone(schema),
+        )
+        .unwrap(),
+    );
+
+    if !two_stage {
+        return partial;
+    }
+
+    Arc::new(
+        AggregateExec::try_new(
+            AggregateMode::Final,
+            PhysicalGroupBy::new_single(vec![(key, "key".to_string())]),
+            vec![aggr_expr],
+            vec![None],
+            partial,
+            Arc::clone(schema),
+        )
+        .unwrap(),
+    )
+}
+
+fn build_batches(
+    element: Element,
+    num_groups: usize,
+    num_values: usize,
+    null_every: usize,
+    clustered: bool,
+) -> Vec<RecordBatch> {
+    let value_field = match element {
+        Element::Int64 => Field::new("value", DataType::Int64, true),
+        Element::Utf8 => Field::new("value", DataType::Utf8, true),
+        Element::Struct => Field::new("value", 
DataType::Struct(struct_fields()), true),
+    };
+    let schema = Arc::new(Schema::new(vec![
+        Field::new("key", DataType::Utf8, false),
+        value_field,
+    ]));
+
+    (0..NUM_BATCHES)
+        .map(|batch| {
+            let start = batch * BATCH_SIZE;
+            let rows = start..start + BATCH_SIZE;
+
+            let mut keys = StringBuilder::with_capacity(BATCH_SIZE, BATCH_SIZE 
* 16);
+            for i in rows.clone() {
+                // Clustered keys hold for a run of rows, which is what lets 
the accumulator
+                // coalesce them into one range; cycling keys give a fresh 
range per row.
+                let group = if clustered {
+                    (i / BATCH_SIZE.div_ceil(num_groups)) % num_groups
+                } else {
+                    i % num_groups
+                };
+                keys.append_value(format!("key_{group}"));
+            }
+            let keys: ArrayRef = Arc::new(keys.finish());
+
+            let is_null = |i: usize| null_every != 0 && 
i.is_multiple_of(null_every);
+            let values: ArrayRef = match element {
+                Element::Int64 => {
+                    let mut b = Int64Builder::with_capacity(BATCH_SIZE);
+                    for i in rows {
+                        if is_null(i) {
+                            b.append_null();
+                        } else {
+                            b.append_value((i % num_values) as i64);
+                        }
+                    }
+                    Arc::new(b.finish())
+                }
+                Element::Utf8 => {
+                    let mut b = StringBuilder::with_capacity(BATCH_SIZE, 
BATCH_SIZE * 24);
+                    for i in rows {
+                        if is_null(i) {
+                            b.append_null();
+                        } else {
+                            b.append_value(format!("this is value #{}", i % 
num_values));
+                        }
+                    }
+                    Arc::new(b.finish())
+                }
+                Element::Struct => {
+                    let mut a = StringBuilder::with_capacity(BATCH_SIZE, 
BATCH_SIZE * 24);
+                    let mut b = Int64Builder::with_capacity(BATCH_SIZE);
+                    for i in rows {
+                        a.append_value(format!("this is value #{}", i % 
num_values));
+                        b.append_value((i % num_values) as i64);
+                    }
+                    Arc::new(StructArray::new(
+                        struct_fields(),
+                        vec![Arc::new(a.finish()) as ArrayRef, 
Arc::new(b.finish())],
+                        None,
+                    ))
+                }
+            };
+
+            RecordBatch::try_new(Arc::clone(&schema), vec![keys, 
values]).unwrap()
+        })
+        .collect()
+}
+
+fn struct_fields() -> Fields {
+    vec![
+        Arc::new(Field::new("a", DataType::Utf8, true)),
+        Arc::new(Field::new("b", DataType::Int64, true)),
+    ]
+    .into()
+}
+
+fn config() -> Criterion {
+    Criterion::default()
+        .measurement_time(Duration::from_millis(2000))
+        .warm_up_time(Duration::from_millis(500))
+        .sample_size(10)
+}
+
+criterion_group! {
+    name = benches;
+    config = config();
+    targets = criterion_benchmark
+}
+criterion_main!(benches);
diff --git a/native/spark-expr/src/agg_funcs/collect.rs 
b/native/spark-expr/src/agg_funcs/collect.rs
new file mode 100644
index 0000000000..00b2759c25
--- /dev/null
+++ b/native/spark-expr/src/agg_funcs/collect.rs
@@ -0,0 +1,1255 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Spark `collect_list` / `collect_set`, wrapping `datafusion_spark`'s 
implementations with a
+//! `GroupsAccumulator` so grouped aggregation does not go through DataFusion's
+//! `GroupsAccumulatorAdapter`.
+//!
+//! The adapter keeps one boxed `Accumulator` per group and slices every input 
batch into a
+//! per-group `update_batch` call, which dominates the runtime once a query 
groups by a
+//! high-cardinality key. See 
<https://github.com/apache/datafusion-comet/issues/5797>.
+
+use arrow::array::{
+    new_empty_array, Array, ArrayRef, AsArray, BooleanArray, ListArray, 
UInt32Array,
+};
+use arrow::buffer::{NullBuffer, OffsetBuffer};
+use arrow::compute::{cast, concat, interleave, take};
+use arrow::datatypes::{DataType, Field, FieldRef};
+use arrow::row::{RowConverter, SortField};
+use datafusion::common::{
+    internal_datafusion_err, internal_err, DataFusionError, Result as 
DFResult, ScalarValue,
+};
+use datafusion::logical_expr::function::{AccumulatorArgs, StateFieldsArgs};
+use datafusion::logical_expr::{
+    Accumulator, AggregateUDFImpl, EmitTo, GroupsAccumulator, Signature,
+};
+use datafusion_spark::function::aggregate::collect::{SparkCollectList, 
SparkCollectSet};
+use std::mem::{size_of, take as take_field};
+use std::ops::Range;
+use std::sync::Arc;
+use twox_hash::XxHash64;
+
+/// Spark's `collect_list`.
+///
+/// Everything except grouped aggregation is delegated to 
[`SparkCollectList`]; this type only
+/// adds the [`CollectListGroupsAccumulator`].
+#[derive(Debug, PartialEq, Eq, Hash, Default)]
+pub struct CometCollectList {
+    inner: SparkCollectList,
+}
+
+impl CometCollectList {
+    pub fn new() -> Self {
+        Self::default()
+    }
+}
+
+impl AggregateUDFImpl for CometCollectList {
+    fn name(&self) -> &str {
+        self.inner.name()
+    }
+
+    fn signature(&self) -> &Signature {
+        self.inner.signature()
+    }
+
+    fn return_type(&self, arg_types: &[DataType]) -> DFResult<DataType> {
+        self.inner.return_type(arg_types)
+    }
+
+    fn state_fields(&self, args: StateFieldsArgs) -> DFResult<Vec<FieldRef>> {
+        self.inner.state_fields(args)
+    }
+
+    fn accumulator(&self, acc_args: AccumulatorArgs) -> DFResult<Box<dyn 
Accumulator>> {
+        self.inner.accumulator(acc_args)
+    }
+
+    fn default_value(&self, data_type: &DataType) -> DFResult<ScalarValue> {
+        self.inner.default_value(data_type)
+    }
+
+    fn groups_accumulator_supported(&self, args: AccumulatorArgs) -> bool {
+        !args.is_distinct && args.order_bys.is_empty()
+    }
+
+    fn create_groups_accumulator(
+        &self,
+        args: AccumulatorArgs,
+    ) -> DFResult<Box<dyn GroupsAccumulator>> {
+        Ok(Box::new(CollectListGroupsAccumulator::new(
+            args.expr_fields[0].data_type().clone(),
+        )))
+    }
+}
+
+/// Spark's `collect_set`.
+///
+/// Everything except grouped aggregation is delegated to [`SparkCollectSet`].
+#[derive(Debug, PartialEq, Eq, Hash, Default)]
+pub struct CometCollectSet {
+    inner: SparkCollectSet,
+}
+
+impl CometCollectSet {
+    pub fn new() -> Self {
+        Self::default()
+    }
+}
+
+impl AggregateUDFImpl for CometCollectSet {
+    fn name(&self) -> &str {
+        self.inner.name()
+    }
+
+    fn signature(&self) -> &Signature {
+        self.inner.signature()
+    }
+
+    fn return_type(&self, arg_types: &[DataType]) -> DFResult<DataType> {
+        self.inner.return_type(arg_types)
+    }
+
+    fn state_fields(&self, args: StateFieldsArgs) -> DFResult<Vec<FieldRef>> {
+        self.inner.state_fields(args)
+    }
+
+    fn accumulator(&self, acc_args: AccumulatorArgs) -> DFResult<Box<dyn 
Accumulator>> {
+        self.inner.accumulator(acc_args)
+    }
+
+    fn default_value(&self, data_type: &DataType) -> DFResult<ScalarValue> {
+        self.inner.default_value(data_type)
+    }
+
+    fn groups_accumulator_supported(&self, args: AccumulatorArgs) -> bool {
+        !args.is_distinct && args.order_bys.is_empty()
+    }
+
+    fn create_groups_accumulator(
+        &self,
+        args: AccumulatorArgs,
+    ) -> DFResult<Box<dyn GroupsAccumulator>> {
+        Ok(Box::new(CollectSetGroupsAccumulator::try_new(
+            args.expr_fields[0].data_type().clone(),
+        )?))
+    }
+}
+
+/// Casts `array` to `datatype` when the two have drifted apart, which a 
dictionary-encoded input
+/// or nested field nullability surviving a shuffle round trip can both cause. 
The cast is
+/// metadata-only when only nullability differs.
+fn coerce(array: &ArrayRef, datatype: &DataType) -> DFResult<ArrayRef> {
+    if array.data_type() == datatype {
+        Ok(Arc::clone(array))
+    } else {
+        Ok(cast(array.as_ref(), datatype)?)
+    }
+}
+
+/// The list field a `collect_list` / `collect_set` state or result is built 
from. Spark's element
+/// type is always nullable, and the group's own list entry is never null.
+fn list_field(element_type: &DataType) -> FieldRef {
+    Arc::new(Field::new_list_field(element_type.clone(), true))
+}
+
+/// `GroupsAccumulator` for Spark's `collect_list`.
+///
+/// Input arrays are retained rather than copied per group; each one is paired 
with the
+/// `(group, row range)` contributions it makes. `evaluate` then rearranges 
those contributions
+/// into group order with a counting sort and gathers the values in a single 
pass, so the cost is
+/// proportional to the data rather than to the number of groups.
+#[derive(Debug)]
+struct CollectListGroupsAccumulator {
+    /// The element type as declared by the plan.
+    datatype: DataType,
+    /// Source arrays: input arrays from `update_batch`, list values arrays 
from `merge_batch`.
+    batches: Vec<ArrayRef>,
+    /// Contributions of each retained batch, in the order they were seen.
+    ranges: Vec<Vec<RowRange>>,
+    /// Running total of what `batches` and `ranges` hold. `size()` is called 
once per input
+    /// batch, so it cannot afford to walk the retained batches to work this 
out.
+    retained_bytes: usize,
+    num_groups: usize,
+}
+
+/// A run of consecutive rows of one batch that all belong to `group`.
+#[derive(Debug, Clone, Copy, Default)]
+struct RowRange {
+    group: u32,
+    start: u32,
+    len: u32,
+}
+
+impl RowRange {
+    fn end(&self) -> u32 {
+        self.start + self.len
+    }
+}
+
+/// Below this many rows per range, gathering with `interleave` beats slicing 
and concatenating.
+/// Scattered single rows (a high-cardinality `update_batch`) sit at 1 range 
per row; merging
+/// partial states back together sits at one range per group per batch, which 
is far longer.
+const CONCAT_MIN_RANGE_LEN: usize = 4;
+
+impl CollectListGroupsAccumulator {
+    fn new(datatype: DataType) -> Self {
+        Self {
+            datatype,
+            batches: Vec::new(),
+            ranges: Vec::new(),
+            retained_bytes: 0,
+            num_groups: 0,
+        }
+    }
+
+    /// Appends `row` of the batch being accumulated into `ranges`, extending 
the previous range
+    /// when the row continues it.
+    fn push_row(ranges: &mut Vec<RowRange>, group: u32, row: u32) {
+        match ranges.last_mut() {
+            Some(last) if last.group == group && last.end() == row => last.len 
+= 1,
+            _ => ranges.push(RowRange {
+                group,
+                start: row,
+                len: 1,
+            }),
+        }
+    }
+
+    fn retain(&mut self, batch: ArrayRef, mut ranges: Vec<RowRange>) {
+        if ranges.is_empty() {
+            return;
+        }
+        // `ranges` is sized for one range per row; run coalescing can leave 
most of that unused,
+        // and the excess would be retained for as long as the batch is.
+        if ranges.len() * 2 <= ranges.capacity() {
+            ranges.shrink_to_fit();
+        }
+        self.retained_bytes +=
+            batch.get_array_memory_size() + ranges.capacity() * 
size_of::<RowRange>();
+        self.batches.push(batch);
+        self.ranges.push(ranges);
+    }
+
+    fn clear(&mut self) {
+        // `size()` reports capacity rather than length, so release the 
buffers outright.
+        self.batches = Vec::new();
+        self.ranges = Vec::new();
+        self.retained_bytes = 0;
+        self.num_groups = 0;
+    }
+
+    /// Drops the contributions of groups `0..emit_groups` and renumbers what 
is left to start at
+    /// 0. Batches that no longer contribute anything are released, and 
batches only partly
+    /// retained are compacted so their emitted rows stop being pinned.
+    fn compact(&mut self, emit_groups: u32) -> DFResult<()> {
+        let batches = take_field(&mut self.batches);
+        let ranges = take_field(&mut self.ranges);
+        self.retained_bytes = 0;
+        self.num_groups -= emit_groups as usize;
+
+        for (batch, ranges) in batches.into_iter().zip(ranges) {
+            if ranges.iter().all(|r| r.group >= emit_groups) {
+                // Nothing was emitted from this batch, so only the group 
numbering changes and
+                // the batch itself can be kept as it is.
+                let mut ranges = ranges;
+                for range in &mut ranges {
+                    range.group -= emit_groups;
+                }
+                self.retain(batch, ranges);
+                continue;
+            }
+            let mut retained: Vec<RowRange> = Vec::new();
+            let mut retained_rows: Vec<u32> = Vec::new();
+            for range in ranges {
+                if range.group < emit_groups {
+                    continue;
+                }
+                retained.push(RowRange {
+                    group: range.group - emit_groups,
+                    start: retained_rows.len() as u32,
+                    len: range.len,
+                });
+                retained_rows.extend(range.start..range.end());
+            }
+            if retained.is_empty() {
+                continue;
+            }
+            // Compact the batch so the rows this emit released stop being 
pinned.
+            let batch = take(batch.as_ref(), 
&UInt32Array::from(retained_rows), None)?;
+            self.retain(batch, retained);
+        }
+        Ok(())
+    }
+}
+
+impl GroupsAccumulator for CollectListGroupsAccumulator {
+    fn update_batch(
+        &mut self,
+        values: &[ArrayRef],
+        group_indices: &[usize],
+        opt_filter: Option<&BooleanArray>,
+        total_num_groups: usize,
+    ) -> DFResult<()> {
+        self.num_groups = self.num_groups.max(total_num_groups);
+        let input = coerce(&values[0], &self.datatype)?;
+        // Spark's collect_list drops null inputs.
+        let nulls = input.logical_nulls();
+        let mut ranges = Vec::with_capacity(group_indices.len());
+        for (row_idx, &group_idx) in group_indices.iter().enumerate() {
+            if let Some(filter) = opt_filter {
+                if filter.is_null(row_idx) || !filter.value(row_idx) {
+                    continue;
+                }
+            }
+            if nulls.as_ref().is_some_and(|n| n.is_null(row_idx)) {
+                continue;
+            }
+            Self::push_row(&mut ranges, group_idx as u32, row_idx as u32);
+        }
+        self.retain(input, ranges);
+        Ok(())
+    }
+
+    fn merge_batch(
+        &mut self,
+        values: &[ArrayRef],
+        group_indices: &[usize],
+        total_num_groups: usize,
+    ) -> DFResult<()> {
+        self.num_groups = self.num_groups.max(total_num_groups);
+        let Some(lists) = values[0].as_list_opt::<i32>() else {
+            return internal_err!(
+                "collect_list expected a List state, got {:?}",
+                values[0].data_type()
+            );
+        };
+        let elements = coerce(lists.values(), &self.datatype)?;
+        let offsets = lists.offsets();
+        let mut ranges = Vec::with_capacity(group_indices.len());
+        for (row_idx, &group_idx) in group_indices.iter().enumerate() {
+            let (start, end) = (offsets[row_idx], offsets[row_idx + 1]);
+            if lists.is_null(row_idx) || end == start {
+                continue;
+            }
+            ranges.push(RowRange {
+                group: group_idx as u32,
+                start: start as u32,
+                len: (end - start) as u32,
+            });
+        }
+        self.retain(elements, ranges);
+        Ok(())
+    }
+
+    fn evaluate(&mut self, emit_to: EmitTo) -> DFResult<ArrayRef> {
+        let emit_groups = match emit_to {
+            EmitTo::All => self.num_groups,
+            EmitTo::First(n) => n,
+        };
+
+        // Counting sort of the retained ranges into group order: count, 
prefix sum, scatter.
+        // Rows and ranges are counted separately: rows give the output list 
offsets, ranges
+        // decide how the values are gathered.
+        let mut range_counts = vec![0u32; emit_groups];
+        let mut row_counts = vec![0u32; emit_groups];
+        let mut num_ranges = 0usize;
+        let mut num_rows = 0usize;
+        for ranges in &self.ranges {
+            for range in ranges {
+                if (range.group as usize) < emit_groups {
+                    range_counts[range.group as usize] += 1;
+                    row_counts[range.group as usize] += range.len;
+                    num_ranges += 1;
+                    num_rows += range.len as usize;
+                }
+            }
+        }
+        let offsets = 
OffsetBuffer::<i32>::from_lengths(row_counts.iter().map(|c| *c as usize));
+
+        let values = if num_rows == 0 {
+            new_empty_array(&self.datatype)
+        } else if num_rows >= num_ranges * CONCAT_MIN_RANGE_LEN {
+            // Long runs: scatter the ranges themselves into group order, then 
copy each one
+            // whole. `concat` preallocates and does a single copy per run.
+            let mut cursors = prefix_sum(&range_counts);
+            let mut ordered = vec![(0u32, RowRange::default()); num_ranges];
+            for (batch_idx, ranges) in self.ranges.iter().enumerate() {
+                for range in ranges {
+                    let group = range.group as usize;
+                    if group < emit_groups {
+                        ordered[cursors[group] as usize] = (batch_idx as u32, 
*range);
+                        cursors[group] += 1;
+                    }
+                }
+            }
+            let slices: Vec<ArrayRef> = ordered
+                .iter()
+                .map(|(batch_idx, range)| {
+                    self.batches[*batch_idx as usize]
+                        .slice(range.start as usize, range.len as usize)
+                })
+                .collect();
+            let slices: Vec<&dyn Array> = slices.iter().map(|s| 
s.as_ref()).collect();
+            concat(&slices)?
+        } else {
+            // Scattered rows: scatter straight into the gather indices and 
take them all in one
+            // `interleave` call.
+            let mut cursors = prefix_sum(&row_counts);
+            let mut indices = vec![(0usize, 0usize); num_rows];
+            for (batch_idx, ranges) in self.ranges.iter().enumerate() {
+                for range in ranges {
+                    let group = range.group as usize;
+                    if group < emit_groups {
+                        let mut cursor = cursors[group] as usize;
+                        for row in range.start..range.end() {
+                            indices[cursor] = (batch_idx, row as usize);
+                            cursor += 1;
+                        }
+                        cursors[group] = cursor as u32;
+                    }
+                }
+            }
+            let sources: Vec<&dyn Array> = self.batches.iter().map(|b| 
b.as_ref()).collect();
+            interleave(&sources, &indices)?
+        };
+
+        match emit_to {
+            EmitTo::All => self.clear(),
+            EmitTo::First(n) => self.compact(n as u32)?,
+        }
+
+        // A group that collected nothing gets an empty list, not a null one, 
matching Spark.
+        Ok(Arc::new(ListArray::new(
+            list_field(&self.datatype),
+            offsets,
+            values,
+            None,
+        )))
+    }
+
+    fn state(&mut self, emit_to: EmitTo) -> DFResult<Vec<ArrayRef>> {
+        Ok(vec![self.evaluate(emit_to)?])
+    }
+
+    fn convert_to_state(
+        &self,
+        values: &[ArrayRef],
+        opt_filter: Option<&BooleanArray>,
+    ) -> DFResult<Vec<ArrayRef>> {
+        single_row_lists(&values[0], opt_filter, &self.datatype)
+    }
+
+    fn size(&self) -> usize {
+        self.retained_bytes
+            + self.batches.capacity() * size_of::<ArrayRef>()
+            + self.ranges.capacity() * size_of::<Vec<RowRange>>()
+            + self.datatype.size()
+    }
+}
+
+/// Exclusive prefix sum of `counts`, giving each group the position its first 
item is written to.
+fn prefix_sum(counts: &[u32]) -> Vec<u32> {
+    let mut cursors = Vec::with_capacity(counts.len());
+    let mut total = 0u32;
+    for count in counts {
+        cursors.push(total);
+        total += count;
+    }
+    cursors
+}
+
+/// A `NullBuffer` that is null wherever `filter` does not select the row, so 
that a filtered row
+/// can be represented as a null list entry. Mirrors DataFusion's 
`filter_to_nulls`, which lives
+/// in `datafusion-functions-aggregate-common` and is not re-exported by the 
`datafusion` facade.
+fn filter_to_nulls(filter: &BooleanArray) -> NullBuffer {
+    let selected = match filter.nulls() {
+        Some(nulls) => filter.values() & nulls.inner(),
+        None => filter.values().clone(),
+    };
+    NullBuffer::new(selected)
+}
+
+/// Turns every row of `input` into its own single-element list, which is what
+/// `GroupsAccumulator::convert_to_state` has to produce for both collect 
functions. Null and
+/// filtered-out rows become null list entries, which `merge_batch` skips, so 
the input array
+/// itself can back the lists without being copied.
+fn single_row_lists(
+    input: &ArrayRef,
+    opt_filter: Option<&BooleanArray>,
+    datatype: &DataType,
+) -> DFResult<Vec<ArrayRef>> {
+    let filter_nulls = opt_filter.map(filter_to_nulls);
+    let nulls = NullBuffer::union(filter_nulls.as_ref(), 
input.logical_nulls().as_ref());
+    Ok(vec![Arc::new(ListArray::new(
+        list_field(datatype),
+        OffsetBuffer::from_repeated_length(1, input.len()),
+        Arc::clone(input),
+        nulls,
+    ))])
+}
+
+/// One distinct `(group, value)` pair. `start` and `len` address the value's 
row-encoded bytes
+/// in [`CollectSetGroupsAccumulator::arena`], and `hash` is of those bytes 
alone, so it survives
+/// the group renumbering [`CollectSetGroupsAccumulator::compact`] does.
+///
+/// `start` is 64-bit because the arena holds every group's bytes: a large 
execution-memory
+/// budget can push it past 4 GiB while every group's own result, and the 
combined `List<i32>`
+/// element count, stay valid, since that limit counts elements rather than 
encoded bytes. `len`
+/// stays 32-bit and is range-checked on insert, which keeps the struct at 
three machine words.
+/// Field order is chosen so the wider `start` costs nothing: 
`size_of::<SetEntry>()` is pinned
+/// by [`tests::set_entry_stays_three_words`].
+#[derive(Debug, Clone, Copy)]
+struct SetEntry {
+    hash: u64,
+    start: u64,
+    group: u32,
+    len: u32,
+}
+
+/// Largest row-encoded value a [`SetEntry`] can address. A single Spark value 
cannot approach
+/// this, but the conversion is checked rather than truncating.
+const MAX_ENTRY_LEN: usize = u32::MAX as usize;
+
+/// Narrows an encoded value's length to the 32 bits a [`SetEntry`] stores, 
erroring rather than
+/// truncating a length whose low bits would address a shorter, wrong slice.
+fn checked_entry_len(len: usize) -> DFResult<u32> {
+    u32::try_from(len).map_err(|_| {
+        DataFusionError::Execution(format!(
+            "collect_set value of {len} encoded bytes exceeds the 
{MAX_ENTRY_LEN} byte limit"
+        ))
+    })
+}
+
+/// Byte range of `entry` within the arena, computed in 64-bit and widened 
once.
+///
+/// Split out from [`CollectSetGroupsAccumulator::entry_bytes`] so the 
arithmetic can be tested at
+/// the 4 GiB boundary without allocating an arena that large.
+#[inline]
+fn entry_range(start: u64, len: u32) -> Range<usize> {
+    let start = usize::try_from(start).expect("arena offset exceeds the 
address space");
+    let end = start
+        .checked_add(len as usize)
+        .expect("arena entry end exceeds the address space");
+    start..end
+}
+
+/// `GroupsAccumulator` for Spark's `collect_set`.
+///
+/// Values are kept row-encoded (`arrow::row`) in one shared arena, 
deduplicated on insert against
+/// an open-addressed index keyed by `(group, encoded value)`. Encoding is 
what the per-group
+/// `Accumulator` did as well, so which values count as duplicates is 
unchanged; what changes is
+/// that a batch is encoded once for all of its groups instead of once per 
group, and that the
+/// state is flat rather than one hash table plus one `IndexSet` per group.
+#[derive(Debug)]
+struct CollectSetGroupsAccumulator {
+    /// The element type as declared by the plan.
+    datatype: DataType,
+    converter: RowConverter,
+    /// Row-encoded bytes of every distinct `(group, value)` pair, in 
insertion order.
+    arena: Vec<u8>,
+    entries: Vec<SetEntry>,
+    index: RowIndex,
+    num_groups: usize,
+}
+
+impl CollectSetGroupsAccumulator {
+    fn try_new(datatype: DataType) -> DFResult<Self> {
+        let converter = 
RowConverter::new(vec![SortField::new(datatype.clone())])?;
+        Ok(Self {
+            datatype,
+            converter,
+            arena: Vec::new(),
+            entries: Vec::new(),
+            index: RowIndex::default(),
+            num_groups: 0,
+        })
+    }
+
+    /// Records `(group, encoded)` unless the group already holds that value. 
`hash` is of
+    /// `encoded` alone, so a caller that already has it (`compact`) need not 
recompute it.
+    fn insert(&mut self, group: u32, hash: u64, encoded: &[u8]) -> 
DFResult<()> {
+        let len = checked_entry_len(encoded.len())?;
+        let Self {
+            arena,
+            entries,
+            index,
+            ..
+        } = self;
+        let key = key_hash(group, hash);
+        let Some(slot) = index.vacant_slot(key, |entry_idx| {
+            let entry = &entries[entry_idx as usize];
+            entry.group == group
+                && entry.hash == hash
+                && &arena[entry_range(entry.start, entry.len)] == encoded
+        }) else {
+            return Ok(());
+        };
+        let entry_idx = entries.len() as u32;
+        entries.push(SetEntry {
+            hash,
+            start: arena.len() as u64,
+            group,
+            len,
+        });
+        arena.extend_from_slice(encoded);
+        index.fill(slot, entry_idx);
+        if index.is_full(entries.len()) {
+            index.grow(entries.iter().map(|e| key_hash(e.group, e.hash)));
+        }
+        Ok(())
+    }
+
+    fn entry_bytes(&self, entry: &SetEntry) -> &[u8] {
+        &self.arena[entry_range(entry.start, entry.len)]
+    }
+
+    /// Drops the entries of groups `0..emit_groups` and renumbers what is 
left to start at 0.
+    fn compact(&mut self, emit_groups: u32) -> DFResult<()> {
+        let old_entries = take_field(&mut self.entries);
+        let old_arena = take_field(&mut self.arena);
+        self.index = RowIndex::default();
+        self.num_groups -= emit_groups as usize;
+        for entry in old_entries {
+            if entry.group < emit_groups {
+                continue;
+            }
+            let bytes = &old_arena[entry_range(entry.start, entry.len)];
+            self.insert(entry.group - emit_groups, entry.hash, bytes)?;
+        }
+        Ok(())
+    }
+
+    fn clear(&mut self) {
+        // `size()` reports capacity rather than length, so release the 
buffers outright.
+        self.arena = Vec::new();
+        self.entries = Vec::new();
+        self.index = RowIndex::default();
+        self.num_groups = 0;
+    }
+}
+
+impl GroupsAccumulator for CollectSetGroupsAccumulator {
+    fn update_batch(
+        &mut self,
+        values: &[ArrayRef],
+        group_indices: &[usize],
+        opt_filter: Option<&BooleanArray>,
+        total_num_groups: usize,
+    ) -> DFResult<()> {
+        self.num_groups = self.num_groups.max(total_num_groups);
+        let input = coerce(&values[0], &self.datatype)?;
+        let rows = self.converter.convert_columns(&[Arc::clone(&input)])?;
+        // Spark's collect_set drops null inputs.
+        let nulls = input.logical_nulls();
+        for (row_idx, &group_idx) in group_indices.iter().enumerate() {
+            if let Some(filter) = opt_filter {
+                if filter.is_null(row_idx) || !filter.value(row_idx) {
+                    continue;
+                }
+            }
+            if nulls.as_ref().is_some_and(|n| n.is_null(row_idx)) {
+                continue;
+            }
+            let row = rows.row(row_idx);
+            self.insert(group_idx as u32, value_hash(row.as_ref()), 
row.as_ref())?;
+        }
+        Ok(())
+    }
+
+    fn merge_batch(
+        &mut self,
+        values: &[ArrayRef],
+        group_indices: &[usize],
+        total_num_groups: usize,
+    ) -> DFResult<()> {
+        self.num_groups = self.num_groups.max(total_num_groups);
+        let Some(lists) = values[0].as_list_opt::<i32>() else {
+            return internal_err!(
+                "collect_set expected a List state, got {:?}",
+                values[0].data_type()
+            );
+        };
+        let elements = coerce(lists.values(), &self.datatype)?;
+        let rows = self.converter.convert_columns(&[elements])?;
+        let offsets = lists.offsets();
+        for (row_idx, &group_idx) in group_indices.iter().enumerate() {
+            if lists.is_null(row_idx) {
+                continue;
+            }
+            for pos in offsets[row_idx] as usize..offsets[row_idx + 1] as 
usize {
+                let row = rows.row(pos);
+                self.insert(group_idx as u32, value_hash(row.as_ref()), 
row.as_ref())?;
+            }
+        }
+        Ok(())
+    }
+
+    fn evaluate(&mut self, emit_to: EmitTo) -> DFResult<ArrayRef> {
+        let emit_groups = match emit_to {
+            EmitTo::All => self.num_groups,
+            EmitTo::First(n) => n,
+        };
+
+        // Counting sort of the entries into group order: count, prefix sum, 
scatter.
+        let mut counts = vec![0u32; emit_groups];
+        let mut total = 0u32;
+        for entry in &self.entries {
+            if (entry.group as usize) < emit_groups {
+                counts[entry.group as usize] += 1;
+                total += 1;
+            }
+        }
+        let offsets = OffsetBuffer::<i32>::from_lengths(counts.iter().map(|c| 
*c as usize));
+        let mut cursors = prefix_sum(&counts);
+
+        let values = if total == 0 {
+            new_empty_array(&self.datatype)
+        } else {
+            let mut ordered = vec![0u32; total as usize];
+            for (entry_idx, entry) in self.entries.iter().enumerate() {
+                let group = entry.group as usize;
+                if group < emit_groups {
+                    ordered[cursors[group] as usize] = entry_idx as u32;
+                    cursors[group] += 1;
+                }
+            }
+            let parser = self.converter.parser();
+            let decoded = self.converter.convert_rows(
+                ordered
+                    .iter()
+                    .map(|&i| parser.parse(self.entry_bytes(&self.entries[i as 
usize]))),
+            )?;
+            let decoded = decoded
+                .into_iter()
+                .next()
+                .ok_or_else(|| internal_datafusion_err!("collect_set decoded 
no columns"))?;
+            // `RowConverter` always decodes to the physical type, so a 
dictionary element type
+            // has to be restored.
+            coerce(&decoded, &self.datatype)?
+        };
+
+        match emit_to {
+            EmitTo::All => self.clear(),
+            EmitTo::First(n) => self.compact(n as u32)?,
+        }
+
+        Ok(Arc::new(ListArray::new(
+            list_field(&self.datatype),
+            offsets,
+            values,
+            None,
+        )))
+    }
+
+    fn state(&mut self, emit_to: EmitTo) -> DFResult<Vec<ArrayRef>> {
+        Ok(vec![self.evaluate(emit_to)?])
+    }
+
+    fn convert_to_state(
+        &self,
+        values: &[ArrayRef],
+        opt_filter: Option<&BooleanArray>,
+    ) -> DFResult<Vec<ArrayRef>> {
+        // A single-element list is trivially deduplicated; `merge_batch` does 
the real work.
+        single_row_lists(&values[0], opt_filter, &self.datatype)
+    }
+
+    fn size(&self) -> usize {
+        self.arena.capacity()
+            + self.entries.capacity() * size_of::<SetEntry>()
+            + self.index.size()
+            + self.datatype.size()
+    }
+}
+
+/// Hash of a row-encoded value, independent of the group that holds it.
+fn value_hash(encoded: &[u8]) -> u64 {
+    XxHash64::oneshot(0, encoded)
+}
+
+/// Mixes a group index into a value hash. Keeping the two separate lets an 
entry's value hash be
+/// reused when [`CollectSetGroupsAccumulator::compact`] renumbers its group.
+fn key_hash(group: u32, value_hash: u64) -> u64 {
+    value_hash ^ (group as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15)
+}
+
+/// Open-addressed index from a hash to an entry position in
+/// [`CollectSetGroupsAccumulator::entries`], with linear probing and a load 
factor of 0.5.
+///
+/// Slots hold `entry index + 1` so that `0` means empty.
+#[derive(Debug, Default)]
+struct RowIndex {
+    slots: Vec<u32>,
+}
+
+impl RowIndex {
+    const INITIAL_SLOTS: usize = 1024;
+
+    /// The slot `hash` belongs in, or `None` when `eq` matched an entry 
already indexed there.
+    fn vacant_slot(&mut self, hash: u64, eq: impl Fn(u32) -> bool) -> 
Option<usize> {
+        if self.slots.is_empty() {
+            self.slots = vec![0; Self::INITIAL_SLOTS];
+        }
+        let mask = self.slots.len() - 1;
+        let mut slot = hash as usize & mask;
+        loop {
+            match self.slots[slot] {
+                0 => return Some(slot),
+                v if eq(v - 1) => return None,
+                _ => slot = (slot + 1) & mask,
+            }
+        }
+    }
+
+    fn fill(&mut self, slot: usize, entry_idx: u32) {
+        self.slots[slot] = entry_idx + 1;
+    }
+
+    fn is_full(&self, num_entries: usize) -> bool {
+        num_entries * 2 >= self.slots.len()
+    }
+
+    fn grow(&mut self, hashes: impl Iterator<Item = u64>) {
+        let mask = self.slots.len() * 2 - 1;
+        let mut slots = vec![0u32; mask + 1];
+        for (entry_idx, hash) in hashes.enumerate() {
+            let mut slot = hash as usize & mask;
+            while slots[slot] != 0 {
+                slot = (slot + 1) & mask;
+            }
+            slots[slot] = entry_idx as u32 + 1;
+        }
+        self.slots = slots;
+    }
+
+    fn size(&self) -> usize {
+        self.slots.capacity() * size_of::<u32>()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use arrow::array::{
+        Int32Array, Int32Builder, ListBuilder, StringArray, StringBuilder, 
StructArray,
+    };
+    use arrow::datatypes::{Fields, Int32Type};
+
+    /// The collected values of every group, which are never null lists.
+    fn int_groups(array: &ArrayRef) -> Vec<Vec<Option<i32>>> {
+        let list = array.as_list::<i32>();
+        assert_eq!(list.null_count(), 0, "collected lists are never null");
+        (0..list.len())
+            .map(|i| {
+                let values = list.value(i);
+                let values = values.as_primitive::<Int32Type>();
+                (0..values.len())
+                    .map(|j| (!values.is_null(j)).then(|| values.value(j)))
+                    .collect()
+            })
+            .collect()
+    }
+
+    fn string_groups(array: &ArrayRef) -> Vec<Vec<String>> {
+        let list = array.as_list::<i32>();
+        (0..list.len())
+            .map(|i| {
+                let values = list.value(i);
+                let values = values.as_string::<i32>();
+                (0..values.len())
+                    .map(|j| values.value(j).to_string())
+                    .collect()
+            })
+            .collect()
+    }
+
+    fn ints(values: Vec<Option<i32>>) -> ArrayRef {
+        Arc::new(Int32Array::from(values))
+    }
+
+    /// A `List<Int32>` state array, one list per row.
+    fn int_lists(rows: Vec<Option<Vec<i32>>>) -> ArrayRef {
+        let mut builder = ListBuilder::new(Int32Builder::new())
+            .with_field(Field::new_list_field(DataType::Int32, true));
+        for row in rows {
+            match row {
+                Some(values) => {
+                    for value in values {
+                        builder.values().append_value(value);
+                    }
+                    builder.append(true);
+                }
+                None => builder.append(false),
+            }
+        }
+        Arc::new(builder.finish())
+    }
+
+    fn collect_list() -> CollectListGroupsAccumulator {
+        CollectListGroupsAccumulator::new(DataType::Int32)
+    }
+
+    fn collect_set() -> CollectSetGroupsAccumulator {
+        CollectSetGroupsAccumulator::try_new(DataType::Int32).unwrap()
+    }
+
+    #[test]
+    fn collect_list_keeps_input_order_per_group_and_drops_nulls() {
+        let mut acc = collect_list();
+        acc.update_batch(
+            &[ints(vec![Some(1), None, Some(2), Some(3), Some(4)])],
+            &[0, 1, 0, 2, 1],
+            None,
+            3,
+        )
+        .unwrap();
+        let emitted = acc.evaluate(EmitTo::All).unwrap();
+        assert_eq!(
+            int_groups(&emitted),
+            vec![vec![Some(1), Some(2)], vec![Some(4)], vec![Some(3)],]
+        );
+    }
+
+    #[test]
+    fn collect_list_emits_empty_lists_for_groups_that_collected_nothing() {
+        let mut acc = collect_list();
+        // Group 0 only ever sees a null, group 2 is never seen at all.
+        acc.update_batch(&[ints(vec![None, Some(7)])], &[0, 1], None, 3)
+            .unwrap();
+        let emitted = acc.evaluate(EmitTo::All).unwrap();
+        assert_eq!(int_groups(&emitted), vec![vec![], vec![Some(7)], vec![]]);
+    }
+
+    #[test]
+    fn collect_list_honours_the_filter() {
+        let mut acc = collect_list();
+        let filter = BooleanArray::from(vec![Some(true), Some(false), None, 
Some(true)]);
+        acc.update_batch(
+            &[ints(vec![Some(1), Some(2), Some(3), Some(4)])],
+            &[0, 0, 0, 1],
+            Some(&filter),
+            2,
+        )
+        .unwrap();
+        let emitted = acc.evaluate(EmitTo::All).unwrap();
+        assert_eq!(int_groups(&emitted), vec![vec![Some(1)], vec![Some(4)]]);
+    }
+
+    #[test]
+    fn collect_list_merges_partial_states() {
+        let mut acc = collect_list();
+        acc.merge_batch(
+            &[int_lists(vec![Some(vec![1, 2]), Some(vec![3])])],
+            &[0, 1],
+            2,
+        )
+        .unwrap();
+        acc.merge_batch(
+            &[int_lists(vec![Some(vec![4]), None, Some(vec![])])],
+            &[1, 0, 2],
+            3,
+        )
+        .unwrap();
+        let emitted = acc.evaluate(EmitTo::All).unwrap();
+        assert_eq!(
+            int_groups(&emitted),
+            vec![vec![Some(1), Some(2)], vec![Some(3), Some(4)], vec![]]
+        );
+    }
+
+    /// `evaluate` picks its gather by average run length: two runs of 32 take 
the `concat`
+    /// path, 64 alternating rows the `interleave` one. Both have to collect 
the right values.
+    #[test]
+    fn collect_list_gathers_runs_and_scattered_rows_alike() {
+        let values = || ints((0..64).map(Some).collect());
+
+        let mut clustered = collect_list();
+        let groups: Vec<usize> = (0..64).map(|i| i / 32).collect();
+        clustered
+            .update_batch(&[values()], &groups, None, 2)
+            .unwrap();
+        assert_eq!(
+            int_groups(&clustered.evaluate(EmitTo::All).unwrap()),
+            vec![
+                (0..32).map(Some).collect::<Vec<_>>(),
+                (32..64).map(Some).collect::<Vec<_>>(),
+            ]
+        );
+
+        let mut scattered = collect_list();
+        let groups: Vec<usize> = (0..64).map(|i| i % 2).collect();
+        scattered
+            .update_batch(&[values()], &groups, None, 2)
+            .unwrap();
+        assert_eq!(
+            int_groups(&scattered.evaluate(EmitTo::All).unwrap()),
+            vec![
+                (0..64).step_by(2).map(Some).collect::<Vec<_>>(),
+                (1..64).step_by(2).map(Some).collect::<Vec<_>>(),
+            ]
+        );
+    }
+
+    #[test]
+    fn collect_list_emit_first_keeps_the_remaining_groups() {
+        let mut acc = collect_list();
+        acc.update_batch(
+            &[ints(vec![Some(1), Some(2), Some(3), Some(4)])],
+            &[0, 1, 2, 0],
+            None,
+            3,
+        )
+        .unwrap();
+        let emitted = acc.evaluate(EmitTo::First(2)).unwrap();
+        assert_eq!(
+            int_groups(&emitted),
+            vec![vec![Some(1), Some(4)], vec![Some(2)]]
+        );
+
+        // Group 2 is renumbered to 0, and a further batch lands on the 
renumbered groups.
+        acc.update_batch(&[ints(vec![Some(5), Some(6)])], &[0, 1], None, 2)
+            .unwrap();
+        let emitted = acc.evaluate(EmitTo::All).unwrap();
+        assert_eq!(
+            int_groups(&emitted),
+            vec![vec![Some(3), Some(5)], vec![Some(6)]]
+        );
+    }
+
+    #[test]
+    fn collect_list_converts_rows_to_single_element_states() {
+        let acc = collect_list();
+        let filter = BooleanArray::from(vec![true, true, false]);
+        let states = acc
+            .convert_to_state(&[ints(vec![Some(1), None, Some(3)])], 
Some(&filter))
+            .unwrap();
+        let mut merged = collect_list();
+        merged.merge_batch(&states, &[0, 0, 0], 1).unwrap();
+        assert_eq!(
+            int_groups(&merged.evaluate(EmitTo::All).unwrap()),
+            vec![vec![Some(1)]]
+        );
+    }
+
+    #[test]
+    fn collect_set_deduplicates_per_group_and_drops_nulls() {
+        let mut acc = collect_set();
+        acc.update_batch(
+            &[ints(vec![Some(1), Some(1), None, Some(2), Some(1)])],
+            &[0, 0, 0, 0, 1],
+            None,
+            2,
+        )
+        .unwrap();
+        let emitted = acc.evaluate(EmitTo::All).unwrap();
+        // The same value in a different group is a different entry.
+        assert_eq!(
+            int_groups(&emitted),
+            vec![vec![Some(1), Some(2)], vec![Some(1)]]
+        );
+    }
+
+    #[test]
+    fn collect_set_emits_empty_lists_for_groups_that_collected_nothing() {
+        let mut acc = collect_set();
+        acc.update_batch(&[ints(vec![None, Some(7)])], &[0, 1], None, 3)
+            .unwrap();
+        let emitted = acc.evaluate(EmitTo::All).unwrap();
+        assert_eq!(int_groups(&emitted), vec![vec![], vec![Some(7)], vec![]]);
+    }
+
+    #[test]
+    fn collect_set_deduplicates_across_merged_states() {
+        let mut acc = collect_set();
+        acc.merge_batch(
+            &[int_lists(vec![Some(vec![1, 2]), Some(vec![3])])],
+            &[0, 1],
+            2,
+        )
+        .unwrap();
+        acc.merge_batch(&[int_lists(vec![Some(vec![2, 4]), None])], &[0, 1], 2)
+            .unwrap();
+        let emitted = acc.evaluate(EmitTo::All).unwrap();
+        assert_eq!(
+            int_groups(&emitted),
+            vec![vec![Some(1), Some(2), Some(4)], vec![Some(3)]]
+        );
+    }
+
+    #[test]
+    fn collect_set_emit_first_keeps_the_remaining_groups() {
+        let mut acc = collect_set();
+        acc.update_batch(
+            &[ints(vec![Some(1), Some(2), Some(3), Some(1)])],
+            &[0, 1, 2, 0],
+            None,
+            3,
+        )
+        .unwrap();
+        let emitted = acc.evaluate(EmitTo::First(2)).unwrap();
+        assert_eq!(int_groups(&emitted), vec![vec![Some(1)], vec![Some(2)]]);
+
+        acc.update_batch(&[ints(vec![Some(3), Some(5)])], &[0, 0], None, 1)
+            .unwrap();
+        let emitted = acc.evaluate(EmitTo::All).unwrap();
+        // Group 2 became group 0 and already held 3, so the repeat is dropped.
+        assert_eq!(int_groups(&emitted), vec![vec![Some(3), Some(5)]]);
+    }
+
+    #[test]
+    fn collect_set_grows_its_index_past_the_initial_capacity() {
+        let mut acc = collect_set();
+        let values: Vec<Option<i32>> = (0..4096).map(Some).collect();
+        let groups: Vec<usize> = (0..4096).map(|i| i % 4).collect();
+        acc.update_batch(&[ints(values)], &groups, None, 4).unwrap();
+        // Every value is distinct, so nothing is deduplicated away.
+        let emitted = acc.evaluate(EmitTo::All).unwrap();
+        let collected = int_groups(&emitted);
+        assert_eq!(collected.iter().map(Vec::len).sum::<usize>(), 4096);
+        assert_eq!(collected[0][..2], [Some(0), Some(4)]);
+    }
+
+    #[test]
+    fn collect_set_deduplicates_strings() {
+        let mut acc = 
CollectSetGroupsAccumulator::try_new(DataType::Utf8).unwrap();
+        let values: ArrayRef = Arc::new(StringArray::from(vec![
+            Some("a"),
+            Some("b"),
+            Some("a"),
+            None,
+            Some("b"),
+        ]));
+        acc.update_batch(&[values], &[0, 0, 0, 0, 1], None, 2)
+            .unwrap();
+        let emitted = acc.evaluate(EmitTo::All).unwrap();
+        assert_eq!(
+            string_groups(&emitted),
+            vec![
+                vec!["a".to_string(), "b".to_string()],
+                vec!["b".to_string()]
+            ]
+        );
+    }
+
+    #[test]
+    fn collect_set_deduplicates_structs() {
+        let fields: Fields = vec![
+            Arc::new(Field::new("a", DataType::Utf8, true)),
+            Arc::new(Field::new("b", DataType::Int32, true)),
+        ]
+        .into();
+        let mut names = StringBuilder::new();
+        let mut numbers = Int32Builder::new();
+        for (name, number) in [("x", 1), ("x", 1), ("x", 2)] {
+            names.append_value(name);
+            numbers.append_value(number);
+        }
+        let values: ArrayRef = Arc::new(StructArray::new(
+            fields.clone(),
+            vec![Arc::new(names.finish()), Arc::new(numbers.finish())],
+            None,
+        ));
+
+        let mut acc =
+            
CollectSetGroupsAccumulator::try_new(DataType::Struct(fields.clone())).unwrap();
+        acc.update_batch(&[values], &[0, 0, 0], None, 1).unwrap();
+        let emitted = acc.evaluate(EmitTo::All).unwrap();
+        let list = emitted.as_list::<i32>();
+        assert_eq!(list.len(), 1);
+        // `{x, 1}` collapses into one entry, `{x, 2}` stays separate.
+        assert_eq!(list.value(0).len(), 2);
+        assert_eq!(list.value(0).data_type(), &DataType::Struct(fields));
+    }
+
+    /// The two UDFs forward the rest of `AggregateUDFImpl` to 
`datafusion_spark`, and the
+    /// planner and the `GroupsAccumulator` both depend on what those forwards 
report.
+    /// The arena addresses every group's bytes at once, so its offsets have 
to stay valid past
+    /// 4 GiB. The old 32-bit `start`/`len` pair wrapped there: `0xFFFF_FFFB + 
8` came back as 3,
+    /// and the resulting slice either panicked or read another entry's bytes. 
Nothing about a
+    /// group's own result or the combined `List<i32>` element count bounds 
the arena, because that
+    /// limit counts elements rather than encoded bytes.
+    ///
+    /// Checked here on the range arithmetic rather than through a real 
accumulator, so the
+    /// boundary is covered without allocating a 4 GiB arena.
+    #[test]
+    fn entry_range_stays_valid_past_four_gibibytes() {
+        const FOUR_GIB: u64 = u32::MAX as u64 + 1;
+
+        // Straddling the boundary: the wrapping version produced 3..3 for 
this pair.
+        assert_eq!(
+            entry_range(u32::MAX as u64 - 4, 8),
+            4_294_967_291..4_294_967_299
+        );
+        // Wholly above it.
+        assert_eq!(entry_range(FOUR_GIB, 8), 4_294_967_296..4_294_967_304);
+        // A later start is no longer truncated back into the first 4 GiB.
+        assert_eq!(entry_range(FOUR_GIB + 16, 0).start, 4_294_967_312);
+        // Ordinary offsets are unaffected.
+        assert_eq!(entry_range(0, 0), 0..0);
+        assert_eq!(entry_range(24, 8), 24..32);
+    }
+
+    /// Widening `start` to 64 bits is only free because of the field order. 
If someone reorders
+    /// `SetEntry`, padding appears and every distinct value in the aggregate 
pays 8 more bytes,
+    /// which `size()` reports to the memory pool.
+    #[test]
+    fn set_entry_stays_three_words() {
+        assert_eq!(size_of::<SetEntry>(), 3 * size_of::<u64>());
+    }
+
+    /// `len` stays 32-bit, so it is the one narrowing conversion left. 
Reaching it through the
+    /// accumulator would need a single row-encoded value over 4 GiB, so the 
conversion is checked
+    /// on its own: it must error rather than truncate a length into a shorter 
slice that would
+    /// silently return part of a value.
+    #[test]
+    fn oversized_entry_len_is_rejected_rather_than_truncated() {
+        assert_eq!(checked_entry_len(0).unwrap(), 0);
+        assert_eq!(checked_entry_len(24).unwrap(), 24);
+        assert_eq!(checked_entry_len(MAX_ENTRY_LEN).unwrap(), u32::MAX);
+        let err = checked_entry_len(MAX_ENTRY_LEN + 1).unwrap_err();
+        assert!(err.to_string().contains("exceeds"), "{err}");
+    }
+
+    /// The ordinary path still round-trips through the widened representation.
+    #[test]
+    fn entry_bytes_round_trip_through_the_arena() {
+        let mut acc = 
CollectSetGroupsAccumulator::try_new(DataType::Int32).unwrap();
+        acc.insert(0, 1, &[1, 2, 3]).unwrap();
+        acc.insert(1, 2, &[4, 5]).unwrap();
+        acc.insert(0, 1, &[1, 2, 3]).unwrap(); // duplicate, not stored again
+        assert_eq!(acc.entries.len(), 2);
+        assert_eq!(acc.entry_bytes(&acc.entries[0]), &[1, 2, 3]);
+        assert_eq!(acc.entry_bytes(&acc.entries[1]), &[4, 5]);
+        assert_eq!(acc.entries[1].start, 3);
+    }
+
+    #[test]
+    fn collect_functions_forward_their_shape_to_datafusion_spark() {
+        for udf in [
+            Box::new(CometCollectList::new()) as Box<dyn AggregateUDFImpl>,
+            Box::new(CometCollectSet::new()),
+        ] {
+            let name = udf.name().to_string();
+            assert!(name == "collect_list" || name == "collect_set", "{name}");
+            assert_eq!(
+                udf.return_type(&[DataType::Int32]).unwrap(),
+                DataType::List(list_field(&DataType::Int32))
+            );
+        }
+    }
+}
diff --git a/native/spark-expr/src/agg_funcs/mod.rs 
b/native/spark-expr/src/agg_funcs/mod.rs
index d0d8083195..fcbe465c78 100644
--- a/native/spark-expr/src/agg_funcs/mod.rs
+++ b/native/spark-expr/src/agg_funcs/mod.rs
@@ -18,6 +18,7 @@
 mod approx_percentile;
 mod avg;
 mod avg_decimal;
+mod collect;
 mod correlation;
 mod covariance;
 mod hll_plus_plus;
@@ -36,6 +37,7 @@ mod welford;
 pub use approx_percentile::ApproxPercentile;
 pub use avg::Avg;
 pub use avg_decimal::AvgDecimal;
+pub use collect::{CometCollectList, CometCollectSet};
 pub use correlation::Correlation;
 pub use covariance::Covariance;
 pub use hll_plus_plus::{hllpp_precision, HllPlusPlus};
diff --git 
a/spark/src/test/scala/org/apache/comet/exec/CometAggregateSuite.scala 
b/spark/src/test/scala/org/apache/comet/exec/CometAggregateSuite.scala
index 3592b3b362..23c6b53509 100644
--- a/spark/src/test/scala/org/apache/comet/exec/CometAggregateSuite.scala
+++ b/spark/src/test/scala/org/apache/comet/exec/CometAggregateSuite.scala
@@ -185,6 +185,29 @@ class CometAggregateSuite extends CometTestBase with 
AdaptiveSparkPlanHelper {
       classOf[LocalTableScanExec])
   }
 
+  test("grouped collect_list/collect_set over nulls, duplicates and several 
batches") {
+    // Grouped collect_list/collect_set are served by a native 
GroupsAccumulator rather than one
+    // boxed accumulator per group, so the group's identity travels with each 
row instead of being
+    // implied by which accumulator was called. This covers the cases where 
that bookkeeping shows:
+    // a group whose every input is NULL (Spark returns an empty array, not 
NULL), repeated values
+    // (collect_list keeps every copy, collect_set one), and enough rows that 
a group is fed by
+    // several batches and, after the exchange, by several partial states.
+    withSQLConf(CometConf.COMET_BATCH_SIZE.key -> "128") {
+      val data = (0 until 2000).map { i =>
+        val group = i % 37
+        (group, if (group == 5) None else Some(i % 11))
+      }
+      withParquetTable(data, "tbl") {
+        checkSparkAnswerAndOperator(sql("""
+          SELECT _1,
+                 sort_array(collect_list(_2)),
+                 sort_array(collect_set(_2)),
+                 size(collect_list(_2))
+          FROM tbl GROUP BY _1"""))
+      }
+    }
+  }
+
   test("collect_list/collect_set combined with distinct aggregate falls back 
safely") {
     // SPARK-17616: a distinct aggregate combined with 
collect_list/collect_set produces a
     // multi-stage plan where the buffer-producing Partial may run in Spark 
(e.g. over a


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to