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

ulysses-you pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/master by this push:
     new 1db5e6863f7f [SPARK-32750][SQL] Support whole-stage codegen for 
SortAggregate with grouping keys
1db5e6863f7f is described below

commit 1db5e6863f7f99a228f5b97664bb4a5c81def567
Author: Xiduo You <[email protected]>
AuthorDate: Wed Jul 15 14:52:21 2026 +0800

    [SPARK-32750][SQL] Support whole-stage codegen for SortAggregate with 
grouping keys
    
    ### What changes were proposed in this pull request?
    
    This PR adds whole-stage code-gen support for `SortAggregateExec` when it 
has grouping
    keys. Previously only the no-grouping-keys path was code-gen'd; with keys 
it fell back to
    the interpreted `SortBasedAggregationIterator`.
    
    The implementation:
    - `AggregateCodegenSupport` is refactored to share the aggregation-buffer 
creation
      (`createAggBufVars`) and buffer-update (`generateAggBufferUpdateCode`) 
logic between the
      no-keys path and the new sort-based with-keys path.
    - `SortAggregateExec` implements `doProduceWithKeys` / `doConsumeWithKeys`. 
Since the input
      is sorted by the grouping keys, a group's result is emitted as soon as 
the next group
      starts (detected by comparing the binary representation of the grouping 
key). The produce
      loop is resumable across `processNext` invocations via `shouldStop()`, so 
a completed
      group's output row is not overwritten by the reused output buffer.
    - The with-keys path only supports binary-stable grouping-key types 
(guarded in
      `supportCodegen`), because group boundaries are detected via 
`UnsafeRow.equals`.
    - A new internal config 
`spark.sql.codegen.aggregate.sortAggregate.withKeys.enabled`
      (default true) gates this path; it takes effect only when
      `spark.sql.codegen.aggregate.sortAggregate.enabled` is enabled.
    
    ### Why are the changes needed?
    
    Sort aggregate with grouping keys was the only remaining aggregate path 
without whole-stage
    code-gen, forcing it through the slower interpreted iterator. 
`SortAggregateBenchmark` shows
    a consistent 1.2~1.3x speedup for grouped aggregates on the code-gen path.
    
    ### Does this PR introduce _any_ user-facing change?
    
    No. This is an internal code-gen optimization; results are unchanged. The 
new config is
    `internal()`.
    
    ### How was this patch tested?
    
    - New tests in `WholeStageCodegenSuite` covering: multiple/numeric/string 
grouping keys,
      float/double keys (including `-0.0` and `NaN`), decimal keys, null keys 
and values,
      single-row groups, a single all-rows group, grouping-only aggregates (no 
aggregate
      function, e.g. `DISTINCT`), downstream limit (resumable `shouldStop` 
path), empty input,
      single partition, split aggregate functions, FILTER clauses, HAVING-style 
filters, and the
      config gate. Each test asserts a code-gen'd `SortAggregateExec` in the 
plan and checks the
      result matches the interpreted (code-gen disabled) result.
    - Added `SortAggregateBenchmark` with results committed for JDK 17/21/25.
    
    The generated code of a simple query: `select id , count(*) from t1 group 
by id`:
    
    ```
    /* 001 */ public Object generate(Object[] references) {
    /* 002 */   return new GeneratedIteratorForCodegenStage1(references);
    /* 003 */ }
    /* 004 */
    /* 005 */ // codegenStageId=1
    /* 006 */ final class GeneratedIteratorForCodegenStage1 extends 
org.apache.spark.sql.execution.BufferedRowIterator {
    /* 007 */   private Object[] references;
    /* 008 */   private scala.collection.Iterator[] inputs;
    /* 009 */   private boolean sortAgg_bufIsNull_0;
    /* 010 */   private long sortAgg_bufValue_0;
    /* 011 */   private UnsafeRow sortAgg_currentGroupingKey_0;
    /* 012 */   private boolean sortAgg_initGroup_0;
    /* 013 */   private boolean sortAgg_noMoreInputTerm_0;
    /* 014 */   private boolean sort_needToSort_0;
    /* 015 */   private org.apache.spark.sql.execution.UnsafeExternalRowSorter 
sort_sorter_0;
    /* 016 */   private org.apache.spark.executor.TaskMetrics sort_metrics_0;
    /* 017 */   private scala.collection.Iterator<UnsafeRow> sort_sortedIter_0;
    /* 018 */   private int columnartorow_batchIdx_0;
    /* 019 */   private org.apache.spark.sql.vectorized.ColumnarBatch[] 
columnartorow_mutableStateArray_1 = new 
org.apache.spark.sql.vectorized.ColumnarBatch[1];
    /* 020 */   private 
org.apache.spark.sql.execution.vectorized.OnHeapColumnVector[] 
columnartorow_mutableStateArray_2 = new 
org.apache.spark.sql.execution.vectorized.OnHeapColumnVector[1];
    /* 021 */   private scala.collection.Iterator[] 
columnartorow_mutableStateArray_0 = new scala.collection.Iterator[1];
    /* 022 */   private 
org.apache.spark.sql.catalyst.expressions.codegen.UnsafeRowWriter[] 
sortAgg_mutableStateArray_0 = new 
org.apache.spark.sql.catalyst.expressions.codegen.UnsafeRowWriter[3];
    /* 023 */
    /* 024 */   public GeneratedIteratorForCodegenStage1(Object[] references) {
    /* 025 */     this.references = references;
    /* 026 */   }
    /* 027 */
    /* 028 */   public void init(int index, scala.collection.Iterator[] inputs) 
{
    /* 029 */     partitionIndex = index;
    /* 030 */     this.inputs = inputs;
    /* 031 */
    /* 032 */     sortAgg_mutableStateArray_0[0] = new 
org.apache.spark.sql.catalyst.expressions.codegen.UnsafeRowWriter(2, 0);
    /* 033 */     sort_needToSort_0 = true;
    /* 034 */     sort_sorter_0 = ((org.apache.spark.sql.execution.SortExec) 
references[1] /* plan */).createSorter();
    /* 035 */     sort_metrics_0 = 
org.apache.spark.TaskContext.get().taskMetrics();
    /* 036 */     columnartorow_mutableStateArray_0[0] = inputs[0];
    /* 037 */     sortAgg_mutableStateArray_0[1] = new 
org.apache.spark.sql.catalyst.expressions.codegen.UnsafeRowWriter(1, 0);
    /* 038 */     sortAgg_mutableStateArray_0[2] = new 
org.apache.spark.sql.catalyst.expressions.codegen.UnsafeRowWriter(1, 0);
    /* 039 */
    /* 040 */   }
    /* 041 */
    /* 042 */   private void sortAgg_doConsume_0(InternalRow sort_outputRow_0, 
long sortAgg_expr_0_0, boolean sortAgg_exprIsNull_0_0) throws 
java.io.IOException {
    /* 043 */     sortAgg_mutableStateArray_0[2].reset();
    /* 044 */
    /* 045 */     sortAgg_mutableStateArray_0[2].zeroOutNullBytes();
    /* 046 */
    /* 047 */     sortAgg_mutableStateArray_0[2].writeNullable(0, 
sortAgg_expr_0_0, sortAgg_exprIsNull_0_0);
    /* 048 */     if (!sortAgg_initGroup_0) {
    /* 049 */       sortAgg_initGroup_0 = true;
    /* 050 */       sortAgg_currentGroupingKey_0 = 
(sortAgg_mutableStateArray_0[2].getRow()).copy();
    /* 051 */       sortAgg_bufIsNull_0 = false;
    /* 052 */       sortAgg_bufValue_0 = 0L;
    /* 053 */     } else if 
(!sortAgg_currentGroupingKey_0.equals((sortAgg_mutableStateArray_0[2].getRow())))
 {
    /* 054 */       sortAgg_doAggregateWithKeysOutput_0();
    /* 055 */       sortAgg_currentGroupingKey_0 = 
(sortAgg_mutableStateArray_0[2].getRow()).copy();
    /* 056 */       sortAgg_bufIsNull_0 = false;
    /* 057 */       sortAgg_bufValue_0 = 0L;
    /* 058 */     }
    /* 059 */
    /* 060 */     // do aggregate
    /* 061 */     // common sub-expressions
    /* 062 */
    /* 063 */     // evaluate aggregate functions and update aggregation buffers
    /* 064 */
    /* 065 */     long sortAgg_value_7 = -1L;
    /* 066 */
    /* 067 */     sortAgg_value_7 = 
org.apache.spark.sql.catalyst.util.MathUtils.addExact(sortAgg_bufValue_0, 1L, 
((org.apache.spark.sql.catalyst.trees.SQLQueryContext) references[7] /* errCtx 
*/));
    /* 068 */
    /* 069 */     sortAgg_bufIsNull_0 = false;
    /* 070 */     sortAgg_bufValue_0 = sortAgg_value_7;
    /* 071 */
    /* 072 */   }
    /* 073 */
    /* 074 */   private void wholestagecodegen_doAggregateWithKeys_0(int 
partitionIndex) throws java.io.IOException {
    /* 075 */     if (sort_needToSort_0) {
    /* 076 */       long sort_spillSizeBefore_0 = 
sort_metrics_0.memoryBytesSpilled();
    /* 077 */       sort_addToSorter_0(partitionIndex);
    /* 078 */       sort_sortedIter_0 = sort_sorter_0.sort();
    /* 079 */       ((org.apache.spark.sql.execution.metric.SQLMetric) 
references[6] /* sortTime */).add(sort_sorter_0.getSortTimeNanos() / 1000000);
    /* 080 */       ((org.apache.spark.sql.execution.metric.SQLMetric) 
references[4] /* peakMemory */).add(sort_sorter_0.getPeakMemoryUsage());
    /* 081 */       ((org.apache.spark.sql.execution.metric.SQLMetric) 
references[5] /* spillSize */).add(sort_metrics_0.memoryBytesSpilled() - 
sort_spillSizeBefore_0);
    /* 082 */       
sort_metrics_0.incPeakExecutionMemory(sort_sorter_0.getPeakMemoryUsage());
    /* 083 */       sort_needToSort_0 = false;
    /* 084 */     }
    /* 085 */
    /* 086 */     while ( sort_sortedIter_0.hasNext()) {
    /* 087 */       UnsafeRow sort_outputRow_0 = 
(UnsafeRow)sort_sortedIter_0.next();
    /* 088 */
    /* 089 */       boolean sort_isNull_0 = sort_outputRow_0.isNullAt(0);
    /* 090 */       long sort_value_0 = sort_isNull_0 ?
    /* 091 */       -1L : (sort_outputRow_0.getLong(0));
    /* 092 */
    /* 093 */       sortAgg_doConsume_0(sort_outputRow_0, sort_value_0, 
sort_isNull_0);
    /* 094 */
    /* 095 */       if (shouldStop()) return;
    /* 096 */     }
    /* 097 */
    /* 098 */   }
    /* 099 */
    /* 100 */   private void sort_addToSorter_0(int partitionIndex) throws 
java.io.IOException {
    /* 101 */     if (columnartorow_mutableStateArray_1[0] == null) {
    /* 102 */       columnartorow_nextBatch_0();
    /* 103 */     }
    /* 104 */     while ( columnartorow_mutableStateArray_1[0] != null) {
    /* 105 */       int columnartorow_numRows_0 = 
columnartorow_mutableStateArray_1[0].numRows();
    /* 106 */       int columnartorow_localEnd_0 = columnartorow_numRows_0 - 
columnartorow_batchIdx_0;
    /* 107 */       for (int columnartorow_localIdx_0 = 0; 
columnartorow_localIdx_0 < columnartorow_localEnd_0; 
columnartorow_localIdx_0++) {
    /* 108 */         int columnartorow_rowIdx_0 = columnartorow_batchIdx_0 + 
columnartorow_localIdx_0;
    /* 109 */         boolean columnartorow_isNull_0 = 
columnartorow_mutableStateArray_2[0].isNullAt(columnartorow_rowIdx_0);
    /* 110 */         long columnartorow_value_0 = columnartorow_isNull_0 ? -1L 
: (columnartorow_mutableStateArray_2[0].getLong(columnartorow_rowIdx_0));
    /* 111 */         sortAgg_mutableStateArray_0[1].reset();
    /* 112 */
    /* 113 */         sortAgg_mutableStateArray_0[1].zeroOutNullBytes();
    /* 114 */
    /* 115 */         sortAgg_mutableStateArray_0[1].writeNullable(0, 
columnartorow_value_0, columnartorow_isNull_0);
    /* 116 */         
sort_sorter_0.insertRow((UnsafeRow)(sortAgg_mutableStateArray_0[1].getRow()));
    /* 117 */         // shouldStop check is eliminated
    /* 118 */       }
    /* 119 */       columnartorow_batchIdx_0 = columnartorow_numRows_0;
    /* 120 */       columnartorow_nextBatch_0();
    /* 121 */     }
    /* 122 */     // clean up resources
    /* 123 */     if (columnartorow_mutableStateArray_1[0] != null) {
    /* 124 */       columnartorow_mutableStateArray_1[0].close();
    /* 125 */     }
    /* 126 */
    /* 127 */   }
    /* 128 */
    /* 129 */   protected void processNext() throws java.io.IOException {
    /* 130 */     if (!sortAgg_noMoreInputTerm_0) {
    /* 131 */       wholestagecodegen_doAggregateWithKeys_0(partitionIndex);
    /* 132 */       if (!shouldStop()) {
    /* 133 */         sortAgg_noMoreInputTerm_0 = true;
    /* 134 */         if (sortAgg_initGroup_0) {
    /* 135 */           sortAgg_doAggregateWithKeysOutput_0();
    /* 136 */         }
    /* 137 */       }
    /* 138 */     }
    /* 139 */   }
    /* 140 */
    /* 141 */   private void columnartorow_nextBatch_0() throws 
java.io.IOException {
    /* 142 */     columnartorow_mutableStateArray_1[0] = 
org.apache.spark.sql.execution.ColumnarToRowExec.advanceBatch(
    /* 143 */       columnartorow_mutableStateArray_0[0], 
columnartorow_mutableStateArray_1[0], 
((org.apache.spark.sql.execution.metric.SQLMetric) references[3] /* 
numInputBatches */), ((org.apache.spark.sql.execution.metric.SQLMetric) 
references[2] /* numOutputRows */));
    /* 144 */     if (columnartorow_mutableStateArray_1[0] != null) {
    /* 145 */       columnartorow_batchIdx_0 = 0;
    /* 146 */       columnartorow_mutableStateArray_2[0] = 
(org.apache.spark.sql.execution.vectorized.OnHeapColumnVector) 
columnartorow_mutableStateArray_1[0].column(0);
    /* 147 */
    /* 148 */     }
    /* 149 */   }
    /* 150 */
    /* 151 */   private void sortAgg_doAggregateWithKeysOutput_0() throws 
java.io.IOException {
    /* 152 */     ((org.apache.spark.sql.execution.metric.SQLMetric) 
references[0] /* numOutputRows */).add(1);
    /* 153 */
    /* 154 */     boolean sortAgg_isNull_1 = 
sortAgg_currentGroupingKey_0.isNullAt(0);
    /* 155 */     long sortAgg_value_1 = sortAgg_isNull_1 ?
    /* 156 */     -1L : (sortAgg_currentGroupingKey_0.getLong(0));
    /* 157 */
    /* 158 */     sortAgg_mutableStateArray_0[0].reset();
    /* 159 */
    /* 160 */     sortAgg_mutableStateArray_0[0].zeroOutNullBytes();
    /* 161 */
    /* 162 */     sortAgg_mutableStateArray_0[0].writeNullable(0, 
sortAgg_value_1, sortAgg_isNull_1);
    /* 163 */
    /* 164 */     sortAgg_mutableStateArray_0[0].write(1, sortAgg_bufValue_0);
    /* 165 */     append((sortAgg_mutableStateArray_0[0].getRow()));
    /* 166 */
    /* 167 */   }
    /* 168 */
    /* 169 */ }
    ```
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code
    
    Closes #57153 from ulysses-you/SPARK-32750-sort-agg-codegen.
    
    Authored-by: Xiduo You <[email protected]>
    Signed-off-by: Xiduo You <[email protected]>
---
 .../src/main/resources/error/error-conditions.json |   5 -
 .../org/apache/spark/sql/internal/SQLConf.scala    |  10 +
 .../SortAggregateBenchmark-jdk21-results.txt       |  72 +++++++
 .../SortAggregateBenchmark-jdk25-results.txt       |  72 +++++++
 .../benchmarks/SortAggregateBenchmark-results.txt  |  72 +++++++
 .../aggregate/AggregateCodegenSupport.scala        |  42 +++-
 .../execution/aggregate/SortAggregateExec.scala    | 218 ++++++++++++++++++++-
 .../sql/execution/WholeStageCodegenSuite.scala     | 209 +++++++++++++++++++-
 .../benchmark/SortAggregateBenchmark.scala         | 151 ++++++++++++++
 9 files changed, 828 insertions(+), 23 deletions(-)

diff --git a/common/utils/src/main/resources/error/error-conditions.json 
b/common/utils/src/main/resources/error/error-conditions.json
index 7d9f4cc7d6f1..6adcf5d23cb7 100644
--- a/common/utils/src/main/resources/error/error-conditions.json
+++ b/common/utils/src/main/resources/error/error-conditions.json
@@ -11633,11 +11633,6 @@
       "AcceptsLatestSeenOffset is not supported with DSv1 streaming source: 
<unsupportedSources>"
     ]
   },
-  "_LEGACY_ERROR_TEMP_3170" : {
-    "message" : [
-      "SortAggregate code-gen does not support grouping keys"
-    ]
-  },
   "_LEGACY_ERROR_TEMP_3173" : {
     "message" : [
       "Cannot specify 'USING index_type' in 'CREATE INDEX'"
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
index 132d38faac7c..efa5b0352113 100644
--- a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
+++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
@@ -3797,6 +3797,16 @@ object SQLConf {
       .booleanConf
       .createWithDefault(true)
 
+  val ENABLE_SORT_AGGREGATE_CODEGEN_WITH_KEYS =
+    buildConf("spark.sql.codegen.aggregate.sortAggregate.withKeys.enabled")
+      .internal()
+      .doc("When true, enable code-gen for sort aggregate with grouping keys. 
Takes effect only " +
+        s"when ${ENABLE_SORT_AGGREGATE_CODEGEN.key} is enabled.")
+      .version("4.3.0")
+      .withBindingPolicy(ConfigBindingPolicy.NOT_APPLICABLE)
+      .booleanConf
+      .createWithDefault(true)
+
   val ENABLE_FULL_OUTER_SHUFFLED_HASH_JOIN_CODEGEN =
     buildConf("spark.sql.codegen.join.fullOuterShuffledHashJoin.enabled")
       .internal()
diff --git a/sql/core/benchmarks/SortAggregateBenchmark-jdk21-results.txt 
b/sql/core/benchmarks/SortAggregateBenchmark-jdk21-results.txt
new file mode 100644
index 000000000000..221ff18c190f
--- /dev/null
+++ b/sql/core/benchmarks/SortAggregateBenchmark-jdk21-results.txt
@@ -0,0 +1,72 @@
+================================================================================================
+sort aggregate without grouping
+================================================================================================
+
+OpenJDK 64-Bit Server VM 21.0.11+10-LTS on Linux 6.17.0-1018-azure
+AMD EPYC 9V74 80-Core Processor
+sort agg w/o group:                       Best Time(ms)   Avg Time(ms)   
Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
+------------------------------------------------------------------------------------------------------------------------
+codegen = F                                       16835          16940         
149         31.1          32.1       1.0X
+codegen = T                                         444            456         
 10       1182.0           0.8      38.0X
+
+
+================================================================================================
+sort aggregate with linear keys
+================================================================================================
+
+OpenJDK 64-Bit Server VM 21.0.11+10-LTS on Linux 6.17.0-1018-azure
+AMD EPYC 9V74 80-Core Processor
+sort agg w linear keys:                   Best Time(ms)   Avg Time(ms)   
Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
+------------------------------------------------------------------------------------------------------------------------
+codegen = F                                       21718          22249         
751          3.9         258.9       1.0X
+codegen = T                                       17500          17624         
114          4.8         208.6       1.2X
+
+
+================================================================================================
+sort aggregate with randomized keys
+================================================================================================
+
+OpenJDK 64-Bit Server VM 21.0.11+10-LTS on Linux 6.17.0-1018-azure
+AMD EPYC 9V74 80-Core Processor
+sort agg w randomized keys:               Best Time(ms)   Avg Time(ms)   
Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
+------------------------------------------------------------------------------------------------------------------------
+codegen = F                                       32644          32806         
229          2.6         389.1       1.0X
+codegen = T                                       29288          29539         
182          2.9         349.1       1.1X
+
+
+================================================================================================
+sort aggregate with string key
+================================================================================================
+
+OpenJDK 64-Bit Server VM 21.0.11+10-LTS on Linux 6.17.0-1018-azure
+AMD EPYC 9V74 80-Core Processor
+sort agg w string key:                    Best Time(ms)   Avg Time(ms)   
Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
+------------------------------------------------------------------------------------------------------------------------
+codegen = F                                        6726           6767         
 57          3.1         320.7       1.0X
+codegen = T                                        5885           5947         
 79          3.6         280.6       1.1X
+
+
+================================================================================================
+sort aggregate with decimal key
+================================================================================================
+
+OpenJDK 64-Bit Server VM 21.0.11+10-LTS on Linux 6.17.0-1018-azure
+AMD EPYC 9V74 80-Core Processor
+sort agg w decimal key:                   Best Time(ms)   Avg Time(ms)   
Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
+------------------------------------------------------------------------------------------------------------------------
+codegen = F                                        3371           3426         
 78          6.2         160.7       1.0X
+codegen = T                                        2530           2563         
 41          8.3         120.6       1.3X
+
+
+================================================================================================
+sort aggregate with multiple key types
+================================================================================================
+
+OpenJDK 64-Bit Server VM 21.0.11+10-LTS on Linux 6.17.0-1018-azure
+AMD EPYC 9V74 80-Core Processor
+sort agg w multiple keys:                 Best Time(ms)   Avg Time(ms)   
Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
+------------------------------------------------------------------------------------------------------------------------
+codegen = F                                        6586           6604         
 26          3.2         314.0       1.0X
+codegen = T                                        6035           6050         
 13          3.5         287.8       1.1X
+
+
diff --git a/sql/core/benchmarks/SortAggregateBenchmark-jdk25-results.txt 
b/sql/core/benchmarks/SortAggregateBenchmark-jdk25-results.txt
new file mode 100644
index 000000000000..12cc5a0fe825
--- /dev/null
+++ b/sql/core/benchmarks/SortAggregateBenchmark-jdk25-results.txt
@@ -0,0 +1,72 @@
+================================================================================================
+sort aggregate without grouping
+================================================================================================
+
+OpenJDK 64-Bit Server VM 25.0.3+9-LTS on Linux 6.17.0-1018-azure
+AMD EPYC 7763 64-Core Processor
+sort agg w/o group:                       Best Time(ms)   Avg Time(ms)   
Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
+------------------------------------------------------------------------------------------------------------------------
+codegen = F                                       16207          17142        
1323         32.4          30.9       1.0X
+codegen = T                                         415            422         
  5       1264.8           0.8      39.1X
+
+
+================================================================================================
+sort aggregate with linear keys
+================================================================================================
+
+OpenJDK 64-Bit Server VM 25.0.3+9-LTS on Linux 6.17.0-1018-azure
+AMD EPYC 7763 64-Core Processor
+sort agg w linear keys:                   Best Time(ms)   Avg Time(ms)   
Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
+------------------------------------------------------------------------------------------------------------------------
+codegen = F                                       19884          20041         
222          4.2         237.0       1.0X
+codegen = T                                       15733          16010         
468          5.3         187.6       1.3X
+
+
+================================================================================================
+sort aggregate with randomized keys
+================================================================================================
+
+OpenJDK 64-Bit Server VM 25.0.3+9-LTS on Linux 6.17.0-1018-azure
+AMD EPYC 7763 64-Core Processor
+sort agg w randomized keys:               Best Time(ms)   Avg Time(ms)   
Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
+------------------------------------------------------------------------------------------------------------------------
+codegen = F                                       28860          28899         
 56          2.9         344.0       1.0X
+codegen = T                                       25706          25781         
 58          3.3         306.4       1.1X
+
+
+================================================================================================
+sort aggregate with string key
+================================================================================================
+
+OpenJDK 64-Bit Server VM 25.0.3+9-LTS on Linux 6.17.0-1018-azure
+AMD EPYC 7763 64-Core Processor
+sort agg w string key:                    Best Time(ms)   Avg Time(ms)   
Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
+------------------------------------------------------------------------------------------------------------------------
+codegen = F                                        6927           6934         
 11          3.0         330.3       1.0X
+codegen = T                                        5836           5902         
 45          3.6         278.3       1.2X
+
+
+================================================================================================
+sort aggregate with decimal key
+================================================================================================
+
+OpenJDK 64-Bit Server VM 25.0.3+9-LTS on Linux 6.17.0-1018-azure
+AMD EPYC 7763 64-Core Processor
+sort agg w decimal key:                   Best Time(ms)   Avg Time(ms)   
Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
+------------------------------------------------------------------------------------------------------------------------
+codegen = F                                        3182           3288         
150          6.6         151.7       1.0X
+codegen = T                                        2390           2438         
 45          8.8         113.9       1.3X
+
+
+================================================================================================
+sort aggregate with multiple key types
+================================================================================================
+
+OpenJDK 64-Bit Server VM 25.0.3+9-LTS on Linux 6.17.0-1018-azure
+AMD EPYC 7763 64-Core Processor
+sort agg w multiple keys:                 Best Time(ms)   Avg Time(ms)   
Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
+------------------------------------------------------------------------------------------------------------------------
+codegen = F                                        6647           6662         
 21          3.2         317.0       1.0X
+codegen = T                                        6057           6080         
 27          3.5         288.8       1.1X
+
+
diff --git a/sql/core/benchmarks/SortAggregateBenchmark-results.txt 
b/sql/core/benchmarks/SortAggregateBenchmark-results.txt
new file mode 100644
index 000000000000..422de3a6713f
--- /dev/null
+++ b/sql/core/benchmarks/SortAggregateBenchmark-results.txt
@@ -0,0 +1,72 @@
+================================================================================================
+sort aggregate without grouping
+================================================================================================
+
+OpenJDK 64-Bit Server VM 17.0.19+10-LTS on Linux 6.17.0-1018-azure
+AMD EPYC 7763 64-Core Processor
+sort agg w/o group:                       Best Time(ms)   Avg Time(ms)   
Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
+------------------------------------------------------------------------------------------------------------------------
+codegen = F                                       17331          17444         
160         30.3          33.1       1.0X
+codegen = T                                         881            885         
  3        595.1           1.7      19.7X
+
+
+================================================================================================
+sort aggregate with linear keys
+================================================================================================
+
+OpenJDK 64-Bit Server VM 17.0.19+10-LTS on Linux 6.17.0-1018-azure
+AMD EPYC 7763 64-Core Processor
+sort agg w linear keys:                   Best Time(ms)   Avg Time(ms)   
Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
+------------------------------------------------------------------------------------------------------------------------
+codegen = F                                       20923          21112         
267          4.0         249.4       1.0X
+codegen = T                                       17299          18455         
680          4.8         206.2       1.2X
+
+
+================================================================================================
+sort aggregate with randomized keys
+================================================================================================
+
+OpenJDK 64-Bit Server VM 17.0.19+10-LTS on Linux 6.17.0-1018-azure
+AMD EPYC 7763 64-Core Processor
+sort agg w randomized keys:               Best Time(ms)   Avg Time(ms)   
Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
+------------------------------------------------------------------------------------------------------------------------
+codegen = F                                       30049          30198         
210          2.8         358.2       1.0X
+codegen = T                                       26696          26804         
101          3.1         318.2       1.1X
+
+
+================================================================================================
+sort aggregate with string key
+================================================================================================
+
+OpenJDK 64-Bit Server VM 17.0.19+10-LTS on Linux 6.17.0-1018-azure
+AMD EPYC 7763 64-Core Processor
+sort agg w string key:                    Best Time(ms)   Avg Time(ms)   
Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
+------------------------------------------------------------------------------------------------------------------------
+codegen = F                                        6220           6264         
 63          3.4         296.6       1.0X
+codegen = T                                        5632           5682         
 95          3.7         268.6       1.1X
+
+
+================================================================================================
+sort aggregate with decimal key
+================================================================================================
+
+OpenJDK 64-Bit Server VM 17.0.19+10-LTS on Linux 6.17.0-1018-azure
+AMD EPYC 7763 64-Core Processor
+sort agg w decimal key:                   Best Time(ms)   Avg Time(ms)   
Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
+------------------------------------------------------------------------------------------------------------------------
+codegen = F                                        3220           3239         
 28          6.5         153.5       1.0X
+codegen = T                                        2482           2529         
 56          8.4         118.4       1.3X
+
+
+================================================================================================
+sort aggregate with multiple key types
+================================================================================================
+
+OpenJDK 64-Bit Server VM 17.0.19+10-LTS on Linux 6.17.0-1018-azure
+AMD EPYC 7763 64-Core Processor
+sort agg w multiple keys:                 Best Time(ms)   Avg Time(ms)   
Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
+------------------------------------------------------------------------------------------------------------------------
+codegen = F                                        6378           6379         
  2          3.3         304.1       1.0X
+codegen = T                                        5660           5754         
 70          3.7         269.9       1.1X
+
+
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/aggregate/AggregateCodegenSupport.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/aggregate/AggregateCodegenSupport.scala
index 352388a6d8a1..884a8188a5ae 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/aggregate/AggregateCodegenSupport.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/aggregate/AggregateCodegenSupport.scala
@@ -45,9 +45,10 @@ trait AggregateCodegenSupport
 
   /**
    * The variables are used as aggregation buffers and each aggregate function 
has one or more
-   * ExprCode to initialize its buffer slots. Only used for aggregation 
without keys.
+   * ExprCode to initialize its buffer slots. Used for aggregation without 
keys, and for sort-based
+   * aggregation with keys (where a single group is aggregated at a time).
    */
-  private var bufVars: Seq[Seq[ExprCode]] = _
+  protected var bufVars: Seq[Seq[ExprCode]] = _
 
   /**
    * Whether this operator needs to build hash table.
@@ -94,13 +95,13 @@ trait AggregateCodegenSupport
   override def usedInputs: AttributeSet = inputSet
 
   /**
-   * The generated code for `doProduce` call when aggregate does not have 
grouping keys.
+   * Creates global mutable state variables to hold the aggregation buffer, 
one nested sequence of
+   * `ExprCode` per aggregate function. The returned `ExprCode`s carry the 
code that (re)initializes
+   * the buffer slots with the aggregate functions' initial values; running 
that code resets the
+   * buffer for a new group. The result is also stored in `bufVars` so that 
`doConsume` can update
+   * the same variables.
    */
-  private def doProduceWithoutKeys(ctx: CodegenContext): String = {
-    val initAgg = ctx.addMutableState(CodeGenerator.JAVA_BOOLEAN, "initAgg")
-    // The generated function doesn't have input row in the code context.
-    ctx.INPUT_ROW = null
-
+  protected def createAggBufVars(ctx: CodegenContext): Seq[Seq[ExprCode]] = {
     // generate variables for aggregation buffer
     val functions = 
aggregateExpressions.map(_.aggregateFunction.asInstanceOf[DeclarativeAggregate])
     val initExpr = functions.map(f => f.initialValues)
@@ -121,9 +122,21 @@ trait AggregateCodegenSupport
           JavaCode.global(value, e.dataType))
       }
     }
-    val flatBufVars = bufVars.flatten
-    val initBufVar = evaluateVariables(flatBufVars)
+    bufVars
+  }
 
+  /**
+   * The generated code for `doProduce` call when aggregate does not have 
grouping keys.
+   */
+  private def doProduceWithoutKeys(ctx: CodegenContext): String = {
+    val initAgg = ctx.addMutableState(CodeGenerator.JAVA_BOOLEAN, "initAgg")
+    // The generated function doesn't have input row in the code context.
+    ctx.INPUT_ROW = null
+    // generate variables for aggregation buffer
+    val flatBufVars = createAggBufVars(ctx).flatten
+    val initBufVar = evaluateVariables(flatBufVars)
+    val functions =
+      
aggregateExpressions.map(_.aggregateFunction.asInstanceOf[DeclarativeAggregate])
     // generate variables for output
     val (resultVars, genResult) = if (modes.contains(Final) || 
modes.contains(Complete)) {
       // evaluate aggregate results
@@ -195,6 +208,15 @@ trait AggregateCodegenSupport
    * The generated code for `doConsume` call when aggregate does not have 
grouping keys.
    */
   private def doConsumeWithoutKeys(ctx: CodegenContext, input: Seq[ExprCode]): 
String = {
+    generateAggBufferUpdateCode(ctx, input)
+  }
+
+  /**
+   * The generated code that evaluates the aggregate functions for one input 
row and updates the
+   * aggregation buffer held in `bufVars`. Shared by the no-keys path and the 
sort-based with-keys
+   * path, both of which keep the aggregation buffer in global mutable state 
variables.
+   */
+  protected def generateAggBufferUpdateCode(ctx: CodegenContext, input: 
Seq[ExprCode]): String = {
     // only have DeclarativeAggregate
     val functions = 
aggregateExpressions.map(_.aggregateFunction.asInstanceOf[DeclarativeAggregate])
     val inputAttrs = functions.flatMap(_.aggBufferAttributes) ++ 
inputAttributes
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/aggregate/SortAggregateExec.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/aggregate/SortAggregateExec.scala
index 06f87af50eb5..4d01e2e7a716 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/aggregate/SortAggregateExec.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/aggregate/SortAggregateExec.scala
@@ -17,14 +17,15 @@
 
 package org.apache.spark.sql.execution.aggregate
 
-import org.apache.spark.SparkUnsupportedOperationException
 import org.apache.spark.rdd.RDD
 import org.apache.spark.sql.catalyst.InternalRow
 import org.apache.spark.sql.catalyst.expressions._
+import org.apache.spark.sql.catalyst.expressions.BindReferences.bindReferences
 import org.apache.spark.sql.catalyst.expressions.aggregate._
-import org.apache.spark.sql.catalyst.expressions.codegen.{CodegenContext, 
ExprCode}
+import org.apache.spark.sql.catalyst.expressions.codegen.{CodegenContext, 
CodeGenerator, ExprCode, GenerateUnsafeProjection}
+import org.apache.spark.sql.catalyst.util.UnsafeRowUtils
 import org.apache.spark.sql.catalyst.util.truncatedString
-import org.apache.spark.sql.execution.{OrderPreservingUnaryExecNode, SparkPlan}
+import org.apache.spark.sql.execution.{CodegenSupport, 
OrderPreservingUnaryExecNode, SparkPlan}
 import org.apache.spark.sql.execution.metric.SQLMetrics
 import org.apache.spark.sql.internal.SQLConf
 
@@ -94,19 +95,222 @@ case class SortAggregateExec(
   }
 
   override def supportCodegen: Boolean = {
-    // TODO(SPARK-32750): Support sort aggregate code-gen with grouping keys
     super.supportCodegen && 
conf.getConf(SQLConf.ENABLE_SORT_AGGREGATE_CODEGEN) &&
-      groupingExpressions.isEmpty
+      (groupingExpressions.isEmpty || supportCodegenWithKeys)
+  }
+
+  private def supportCodegenWithKeys: Boolean = {
+    conf.getConf(SQLConf.ENABLE_SORT_AGGREGATE_CODEGEN_WITH_KEYS) &&
+      groupingExpressions.forall(e => 
UnsafeRowUtils.isBinaryStable(e.dataType))
   }
 
   protected override def needHashTable: Boolean = false
 
+  // For the with-keys path, results are produced incrementally while scanning 
the sorted input: a
+  // group's result is emitted (appended to the output buffer) as soon as the 
next group starts. The
+  // child's producing loop must therefore honor `shouldStop()`, so it yields 
right after a result
+  // row is buffered and resumes scanning on the next `processNext` call (see 
`doProduceWithKeys`).
+  // Otherwise the child would scan the whole partition in one go, and every 
emitted row would alias
+  // the single reused output row buffer. The with-keys path is thus not fully 
blocking. The
+  // without-keys path produces its single result only after the scan 
completes, so the blocking
+  // default (no stop check) still applies there.
+  //
+  // We keep the inherited `needCopyResult = false`, but its original 
justification ("blocking
+  // operators keep the data in some buffer") no longer applies once the 
with-keys path streams
+  // results out mid-scan. It stays safe for a different reason: the 
stop-check discipline above
+  // guarantees at most one output row is buffered before it is consumed, so 
the reused output row
+  // is never aliased across emitted rows, and any in-stage parent that 
multiplies rows (e.g. a
+  // join) declares `needCopyResult = true` itself.
+  override def needStopCheck: Boolean = groupingExpressions.nonEmpty
+
+  override protected def canCheckLimitNotReached: Boolean = 
groupingExpressions.nonEmpty
+
+  // The global UnsafeRow holding the grouping key of the group currently 
being aggregated.
+  private var currentGroupingKeyTerm: String = _
+
+  // The global boolean flag indicating whether the current group has been 
started, i.e. at least
+  // one input row has been processed.
+  private var initGroupTerm: String = _
+
+  // The code that (re)initializes the aggregation buffer variables to the 
initial values of the
+  // aggregate functions. Used to reset the buffer when a new group starts.
+  private var reInitBufferCode: String = _
+
+  // The name of the generated function that outputs the result of the current 
group.
+  private var outputFuncName: String = _
+
+  /**
+   * Generate the code for output. The aggregation buffer is held in the 
global `bufVars` and the
+   * grouping key in `currentGroupingKeyTerm`, both populated while scanning 
the current group.
+   * @return function name for the result code.
+   */
+  private def generateResultFunctionForKeys(ctx: CodegenContext): String = {
+    val funcName = ctx.freshName("doAggregateWithKeysOutput")
+    val numOutput = metricTerm(ctx, "numOutputRows")
+    val flatBufVars = bufVars.flatten
+    val groupingAttributes = groupingExpressions.map(_.toAttribute)
+
+    val body =
+      if (modes.contains(Final) || modes.contains(Complete)) {
+        // generate output using resultExpressions
+        ctx.currentVars = null
+        ctx.INPUT_ROW = currentGroupingKeyTerm
+        val keyVars = groupingExpressions.zipWithIndex.map { case (e, i) =>
+          BoundReference(i, e.dataType, e.nullable).genCode(ctx)
+        }
+        val evaluateKeyVars = evaluateVariables(keyVars)
+        // evaluate the aggregation result from the buffer variables
+        ctx.currentVars = flatBufVars
+        ctx.INPUT_ROW = null
+        val functions =
+          
aggregateExpressions.map(_.aggregateFunction.asInstanceOf[DeclarativeAggregate])
+        val aggResults = bindReferences(
+          functions.map(_.evaluateExpression),
+          aggregateBufferAttributes).map(_.genCode(ctx))
+        val evaluateAggResults = evaluateVariables(aggResults)
+        // generate the final result
+        ctx.currentVars = keyVars ++ aggResults
+        val inputAttrs = groupingAttributes ++ aggregateAttributes
+        val resultVars = bindReferences[Expression](
+          resultExpressions,
+          inputAttrs).map(_.genCode(ctx))
+        val evaluateNondeterministicResults =
+          evaluateNondeterministicVariables(output, resultVars, 
resultExpressions)
+        s"""
+           |$evaluateKeyVars
+           |$evaluateAggResults
+           |$evaluateNondeterministicResults
+           |${consume(ctx, resultVars)}
+         """.stripMargin
+      } else if (modes.contains(Partial) || modes.contains(PartialMerge)) {
+        // resultExpressions are Attributes of groupingExpressions and 
aggregateBufferAttributes.
+        assert(resultExpressions.forall(_.isInstanceOf[Attribute]))
+        assert(resultExpressions.length ==
+          groupingExpressions.length + aggregateBufferAttributes.length)
+
+        ctx.currentVars = null
+        ctx.INPUT_ROW = currentGroupingKeyTerm
+        val keyVars = groupingExpressions.zipWithIndex.map { case (e, i) =>
+          BoundReference(i, e.dataType, e.nullable).genCode(ctx)
+        }
+        val evaluateKeyVars = evaluateVariables(keyVars)
+
+        // the aggregation buffer values are output directly
+        ctx.currentVars = keyVars ++ flatBufVars
+        ctx.INPUT_ROW = null
+        val inputAttrs = resultExpressions.map(_.toAttribute)
+        val resultVars = bindReferences[Expression](
+          resultExpressions,
+          inputAttrs).map(_.genCode(ctx))
+        s"""
+           |$evaluateKeyVars
+           |${consume(ctx, resultVars)}
+         """.stripMargin
+      } else {
+        // generate result based on grouping key
+        ctx.INPUT_ROW = currentGroupingKeyTerm
+        ctx.currentVars = null
+        val resultVars = bindReferences[Expression](
+          resultExpressions,
+          groupingAttributes).map(_.genCode(ctx))
+        val evaluateNondeterministicResults =
+          evaluateNondeterministicVariables(output, resultVars, 
resultExpressions)
+        s"""
+           |$evaluateNondeterministicResults
+           |${consume(ctx, resultVars)}
+         """.stripMargin
+      }
+    ctx.addNewFunction(funcName,
+      s"""
+         |private void $funcName() throws java.io.IOException {
+         |  $numOutput.add(1);
+         |  $body
+         |}
+       """.stripMargin)
+  }
+
   protected override def doProduceWithKeys(ctx: CodegenContext): String = {
-    throw new SparkUnsupportedOperationException("_LEGACY_ERROR_TEMP_3170")
+    ctx.INPUT_ROW = null
+    // Generate the global variables for the aggregation buffer of the current 
group. Capture the
+    // buffer initialization code (and clear it from `bufVars`, so the 
result/update code does not
+    // re-emit it); it is reused in `doConsumeWithKeys` to reset the buffer 
when a new group starts.
+    reInitBufferCode = evaluateVariables(createAggBufVars(ctx).flatten)
+    // Global state to track the current group. Inline the grouping-key row 
(rather than letting it
+    // be compacted into a shared array) so its term is a plain variable name: 
it is used as
+    // `ctx.INPUT_ROW` when generating the output, and an array-subscript term 
there would break the
+    // generated code when key expressions are extracted into split functions.
+    currentGroupingKeyTerm =
+      ctx.addMutableState("UnsafeRow", "currentGroupingKey", forceInline = 
true)
+    initGroupTerm = ctx.addMutableState(CodeGenerator.JAVA_BOOLEAN, 
"initGroup")
+    // Whether the whole sorted input has been consumed.
+    val noMoreInputTerm = ctx.addMutableState(CodeGenerator.JAVA_BOOLEAN, 
"noMoreInputTerm")
+    // Generate the output function before `child.produce`, so that 
`doConsumeWithKeys` can call it
+    // when it detects a group boundary.
+    outputFuncName = generateResultFunctionForKeys(ctx)
+
+    val doAgg = ctx.freshName("doAggregateWithKeys")
+    // Pass `partitionIndex` as a parameter so bare references in the child's
+    // produce resolve to the local, not the protected superclass field.
+    // Required when `addNewFunction` spills this helper to a nested class.
+    val doAggFuncName = ctx.addNewFunction(doAgg,
+      s"""
+         |private void $doAgg(int partitionIndex) throws java.io.IOException {
+         |  ${child.asInstanceOf[CodegenSupport].produce(ctx, this)}
+         |}
+       """.stripMargin)
+
+    // Sort-based aggregation consumes the sorted input row by row, emitting a 
group's result as
+    // soon as the next group starts (see `doConsumeWithKeys`). Emitting a row 
appends to the output
+    // buffer, which makes the child's `shouldStop()` return true, so the 
child's producing loop
+    // returns to here mid-scan. We therefore must be able to resume scanning 
across multiple
+    // `processNext` invocations:
+    //  - `$noMoreInputTerm` guards against re-running once the input is fully 
consumed;
+    //  - after `$doAggFuncName()` returns, `shouldStop()` distinguishes a 
real end-of-input (the
+    //    child's loop exhausted, so `shouldStop()` is false) from a mid-scan 
pause (`shouldStop()`
+    //    is true because an output row is buffered). Only on a real 
end-of-input do we mark the
+    //    scan done and flush the last group. If the last input row also 
triggered an output (so
+    //    `shouldStop()` is still true at exhaustion), the next `processNext` 
re-enters, the child's
+    //    loop produces nothing, `shouldStop()` is then false, and the last 
group is flushed.
+    s"""
+       |if (!$noMoreInputTerm) {
+       |  $doAggFuncName(partitionIndex);
+       |  if (!shouldStop()) {
+       |    $noMoreInputTerm = true;
+       |    if ($initGroupTerm) {
+       |      $outputFuncName();
+       |    }
+       |  }
+       |}
+     """.stripMargin
   }
 
   protected override def doConsumeWithKeys(ctx: CodegenContext, input: 
Seq[ExprCode]): String = {
-    throw new SparkUnsupportedOperationException("_LEGACY_ERROR_TEMP_3170")
+    // Create the grouping key. `ctx.currentVars` is still set to `input` here.
+    val groupingUnsafeRowKeyCode = GenerateUnsafeProjection.createCode(
+      ctx, bindReferences[Expression](groupingExpressions, child.output))
+    val groupingUnsafeRowKey = groupingUnsafeRowKeyCode.value
+    // The code to update the aggregation buffer with the current input row.
+    val updateBufferCode = generateAggBufferUpdateCode(ctx, input)
+
+    // `reInitBufferCode` was captured in `doProduceWithKeys`; it 
(re)initializes the aggregation
+    // buffer variables to the aggregate functions' initial values, resetting 
them for a new group.
+    // The input is sorted by the grouping key, so rows of the same group are 
contiguous. When the
+    // grouping key changes, the current group is complete: output it and 
reset the buffer for the
+    // new group. Group equality uses the binary representation of the key, 
which is valid because
+    // `supportCodegen` restricts grouping keys to binary-stable types.
+    s"""
+       |${groupingUnsafeRowKeyCode.code}
+       |if (!$initGroupTerm) {
+       |  $initGroupTerm = true;
+       |  $currentGroupingKeyTerm = $groupingUnsafeRowKey.copy();
+       |  $reInitBufferCode
+       |} else if (!$currentGroupingKeyTerm.equals($groupingUnsafeRowKey)) {
+       |  $outputFuncName();
+       |  $currentGroupingKeyTerm = $groupingUnsafeRowKey.copy();
+       |  $reInitBufferCode
+       |}
+       |$updateBufferCode
+     """.stripMargin
   }
 
   override def simpleString(maxFields: Int): String = toString(verbose = 
false, maxFields)
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/WholeStageCodegenSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/WholeStageCodegenSuite.scala
index bcd2f5369932..2e73417dd9ed 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/WholeStageCodegenSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/WholeStageCodegenSuite.scala
@@ -33,7 +33,7 @@ import 
org.apache.spark.sql.execution.joins.{BroadcastHashJoinExec, BroadcastNes
 import org.apache.spark.sql.functions._
 import org.apache.spark.sql.internal.SQLConf
 import org.apache.spark.sql.test.SharedSparkSession
-import org.apache.spark.sql.types.{DayTimeIntervalType, DecimalType, 
IntegerType, StringType, StructField, StructType}
+import org.apache.spark.sql.types.{DayTimeIntervalType, DecimalType, 
DoubleType, FloatType, IntegerType, LongType, StringType, StructField, 
StructType}
 
 // Disable AQE because the WholeStageCodegenExec is added when running 
QueryStageExec
 class WholeStageCodegenSuite extends SharedSparkSession
@@ -68,6 +68,213 @@ class WholeStageCodegenSuite extends SharedSparkSession
     }
   }
 
+  // Runs `query` on `data` with sort aggregate forced and its code-gen 
enabled, asserts the plan
+  // actually uses a code-gen'd SortAggregateExec, and checks the result 
matches the interpreted
+  // (code-gen disabled) result.
+  private def checkSortAggregateCodegen(
+      data: Dataset[Row])(query: Dataset[Row] => Dataset[Row]): Unit = {
+    // Disable both hash-based aggregate operators so the planner always picks 
SortAggregateExec.
+    val forceSortAggregate = Seq(
+      SQLConf.USE_HASH_AGG.key -> "false",
+      SQLConf.USE_OBJECT_HASH_AGG.key -> "false")
+    val expected = withSQLConf(
+        (forceSortAggregate :+ (SQLConf.ENABLE_SORT_AGGREGATE_CODEGEN.key -> 
"false")): _*) {
+      val df = query(data)
+      assert(!df.queryExecution.executedPlan.exists(p =>
+        p.isInstanceOf[WholeStageCodegenExec] &&
+          
p.asInstanceOf[WholeStageCodegenExec].child.isInstanceOf[SortAggregateExec]),
+        s"Expected no code-gen'd SortAggregateExec 
in:\n${df.queryExecution.executedPlan}")
+      df.collect()
+    }
+    withSQLConf(
+        (forceSortAggregate :+ (SQLConf.ENABLE_SORT_AGGREGATE_CODEGEN.key -> 
"true")): _*) {
+      val df = query(data)
+      assert(df.queryExecution.executedPlan.exists(p =>
+        p.isInstanceOf[WholeStageCodegenExec] &&
+          
p.asInstanceOf[WholeStageCodegenExec].child.isInstanceOf[SortAggregateExec]),
+        s"Expected a code-gen'd SortAggregateExec 
in:\n${df.queryExecution.executedPlan}")
+      checkAnswer(df, expected)
+    }
+  }
+
+  test("SPARK-32750: SortAggregate code-gen with grouping keys") {
+    val data = spark.range(200).selectExpr(
+      "id",
+      "id % 7 as k1",
+      "id % 3 as k2",
+      "case when id % 5 = 0 then null else id end as v",
+      "case when id % 4 = 0 then null else cast(id % 11 as string) end as s")
+
+    // Exercise a variety of shapes: multiple/numeric/string grouping keys, 
null keys and values,
+    // single-row groups, a single all-rows group, and a downstream limit 
(which exercises the
+    // resumable `shouldStop` path in the generated produce loop).
+    checkSortAggregateCodegen(data) {
+      _.groupBy("k1", "k2")
+        .agg(count(col("v")), sum(col("v")), max(col("v")), min(col("v")))
+        .orderBy("k1", "k2")
+    }
+    // expression grouping keys, aggregates over expressions, and arithmetic 
on the results
+    checkSortAggregateCodegen(data) {
+      _.groupBy((col("k1") + col("k2")).as("k"))
+        .agg(
+          (sum(col("v") * lit(2)) + lit(1)).as("weighted"),
+          (max(col("v")) - min(col("v"))).as("spread"),
+          count(col("v")).as("cnt"))
+        .orderBy("k")
+    }
+    // aggregates with FILTER (WHERE) clauses
+    checkSortAggregateCodegen(data) {
+      _.groupBy("k1")
+        .agg(
+          expr("sum(v) FILTER (WHERE v > 50)"),
+          expr("count(v) FILTER (WHERE s IS NOT NULL)"),
+          avg(col("v")))
+        .orderBy("k1")
+    }
+    // string grouping key with nulls, followed by a HAVING-style filter on 
the aggregate
+    checkSortAggregateCodegen(data) {
+      _.groupBy("s").agg(count(col("v")).as("cnt"), sum(col("v")).as("total"))
+        .where(col("cnt") > 2)
+        .orderBy("s")
+    }
+    // count(distinct ...): rewritten to a two-round aggregation, both of 
which are sort aggregates
+    checkSortAggregateCodegen(data) {
+      _.groupBy("k2").agg(countDistinct(col("v")), sum(col("v"))).orderBy("k2")
+    }
+    // every row is its own group
+    
checkSortAggregateCodegen(data)(_.groupBy("id").agg(max(col("v"))).orderBy("id"))
+    // a single group covering all rows
+    checkSortAggregateCodegen(data)(_.groupBy(lit(1)).agg(sum(col("v")), 
avg(col("v"))))
+    // downstream limit on top of the aggregate
+    
checkSortAggregateCodegen(data)(_.groupBy("k1").agg(sum(col("v"))).orderBy("k1").limit(3))
+  }
+
+  test("SPARK-32750: SortAggregate code-gen with grouping keys - empty input") 
{
+    // No rows: a grouped aggregate over empty input must produce no output 
rows.
+    val data = spark.range(0).selectExpr("id", "id % 3 as k", "id as v")
+    checkSortAggregateCodegen(data)(_.groupBy("k").agg(sum(col("v")), 
count(col("v"))).orderBy("k"))
+  }
+
+  test("SPARK-32750: SortAggregate code-gen with grouping keys - single 
partition") {
+    // Force a single partition so all groups are produced within one task's 
scan.
+    val data = spark.range(50).repartition(1).selectExpr("id", "id % 4 as k", 
"id as v")
+    checkSortAggregateCodegen(data) {
+      _.groupBy((col("k") + lit(1)).as("k"))
+        .agg(
+          (sum(col("v")) + max(col("v"))).as("mixed"),
+          avg(col("v") * col("v")).as("avg_sq"),
+          expr("count(v) FILTER (WHERE v % 2 = 0)").as("evens"))
+        .orderBy("k")
+    }
+  }
+
+  test("SPARK-32750: SortAggregate code-gen with float/double grouping keys") {
+    // Float/double are binary-stable so they take the with-keys code-gen 
path, and group
+    // boundaries are detected via UnsafeRow.equals. Cover -0.0 (which must 
group with 0.0) and
+    // NaN (all NaNs must group together), whose canonicalization relies on 
the planner's
+    // NormalizeFloatingNumbers rule running before the keys reach UnsafeRow.
+    val rows = Seq(
+      Row(0.0d, 0.0f, 1L),
+      Row(-0.0d, -0.0f, 2L),
+      Row(Double.NaN, Float.NaN, 3L),
+      Row(java.lang.Double.longBitsToDouble(0x7ff8000000000001L),
+        java.lang.Float.intBitsToFloat(0x7fc00001), 4L),
+      Row(1.5d, 1.5f, 5L),
+      Row(1.5d, 1.5f, 6L),
+      Row(null, null, 7L))
+    val schema = StructType(Seq(
+      StructField("d", DoubleType),
+      StructField("f", FloatType),
+      StructField("v", LongType)))
+    val data = spark.createDataFrame(spark.sparkContext.parallelize(rows), 
schema)
+    checkSortAggregateCodegen(data) {
+      _.groupBy("d").agg(sum(col("v")), count(col("v"))).orderBy("d")
+    }
+    checkSortAggregateCodegen(data) {
+      _.groupBy("f").agg(sum(col("v")), count(col("v"))).orderBy("f")
+    }
+    checkSortAggregateCodegen(data) {
+      _.groupBy("d", "f").agg(sum(col("v"))).orderBy("d", "f")
+    }
+  }
+
+  test("SPARK-32750: SortAggregate code-gen with decimal grouping keys") {
+    // Decimal is binary-stable and takes the with-keys code-gen path. Include 
nulls.
+    val data = spark.range(200).selectExpr(
+      "id",
+      "cast(id % 5 as decimal(10, 2)) as k",
+      "case when id % 6 = 0 then null else cast(id as decimal(20, 4)) end as 
v")
+    checkSortAggregateCodegen(data) {
+      _.groupBy("k").agg(sum(col("v")), count(col("v")), 
max(col("v"))).orderBy("k")
+    }
+  }
+
+  test("SPARK-32750: SortAggregate code-gen with grouping keys - no aggregate 
functions") {
+    // Grouping-only aggregate (DISTINCT lowers to a grouping aggregate with 
no aggregate
+    // functions), exercising the empty-buffer branch of the result code 
generation.
+    val data = spark.range(200).selectExpr("id % 7 as k1", "id % 3 as k2")
+    checkSortAggregateCodegen(data)(_.select("k1").distinct().orderBy("k1"))
+    checkSortAggregateCodegen(data)(_.select("k1", 
"k2").distinct().orderBy("k1", "k2"))
+  }
+
+  test("SPARK-32750: SortAggregate code-gen with grouping keys - split 
aggregate functions") {
+    // Force the aggregate functions into separate split methods so the 
with-keys path is
+    // exercised with split buffer-update code.
+    val data = spark.range(200).selectExpr("id", "id % 7 as k", "id as v")
+    withSQLConf(
+        SQLConf.CODEGEN_SPLIT_AGGREGATE_FUNC.key -> "true",
+        SQLConf.CODEGEN_METHOD_SPLIT_THRESHOLD.key -> "1") {
+      checkSortAggregateCodegen(data) {
+        _.groupBy("k")
+          .agg(sum(col("v")), count(col("v")), max(col("v")), min(col("v")), 
avg(col("v")))
+          .orderBy("k")
+      }
+    }
+  }
+
+  test("SPARK-32750: SortAggregate code-gen with grouping keys - config gate") 
{
+    // When the with-keys config is disabled, the grouped SortAggregate must 
not be code-gen'd,
+    // while the result stays correct.
+    val data = spark.range(200).selectExpr("id % 7 as k", "id as v")
+    withSQLConf(
+        SQLConf.USE_HASH_AGG.key -> "false",
+        SQLConf.USE_OBJECT_HASH_AGG.key -> "false",
+        SQLConf.ENABLE_SORT_AGGREGATE_CODEGEN.key -> "true",
+        SQLConf.ENABLE_SORT_AGGREGATE_CODEGEN_WITH_KEYS.key -> "false") {
+      val df = data.groupBy("k").agg(sum(col("v"))).orderBy("k")
+      assert(!df.queryExecution.executedPlan.exists(p =>
+        p.isInstanceOf[WholeStageCodegenExec] &&
+          
p.asInstanceOf[WholeStageCodegenExec].child.isInstanceOf[SortAggregateExec]),
+        s"Expected no code-gen'd SortAggregateExec 
in:\n${df.queryExecution.executedPlan}")
+      checkAnswer(df, Seq(0, 1, 2, 3, 4, 5, 6).map { k =>
+        Row(k, (k until 200 by 7).map(_.toLong).sum)
+      })
+    }
+    // With the gate enabled it is code-gen'd (baseline for the assertion 
above).
+    
checkSortAggregateCodegen(data)(_.groupBy("k").agg(sum(col("v"))).orderBy("k"))
+  }
+
+  test("SPARK-32750: SortAggregate code-gen with grouping keys - non-binary 
collated key") {
+    // A non-binary collation (e.g. UTF8_LCASE) is not binary-stable, so 
`supportCodegenWithKeys`
+    // returns false and the grouped SortAggregate must fall back to the 
interpreted path rather
+    // than being code-gen'd, while the collation-aware grouping result stays 
correct.
+    val data = Seq("a", "A", "b", "B", "c")
+      .toDF("k").selectExpr("k collate UTF8_LCASE as k")
+    withSQLConf(
+        SQLConf.USE_HASH_AGG.key -> "false",
+        SQLConf.USE_OBJECT_HASH_AGG.key -> "false",
+        SQLConf.ENABLE_SORT_AGGREGATE_CODEGEN.key -> "true",
+        SQLConf.ENABLE_SORT_AGGREGATE_CODEGEN_WITH_KEYS.key -> "true") {
+      val df = data.groupBy("k").agg(count(lit(1)).as("cnt")).orderBy("cnt", 
"k")
+      assert(!df.queryExecution.executedPlan.exists(p =>
+        p.isInstanceOf[WholeStageCodegenExec] &&
+          
p.asInstanceOf[WholeStageCodegenExec].child.isInstanceOf[SortAggregateExec]),
+        s"Expected no code-gen'd SortAggregateExec 
in:\n${df.queryExecution.executedPlan}")
+      // Under UTF8_LCASE, 'a'/'A' and 'b'/'B' each collapse to one group of 
2, 'c' stays alone.
+      checkAnswer(df.select("cnt"), Seq(Row(1), Row(2), Row(2)))
+    }
+  }
+
   testWithWholeStageCodegenOnAndOff("GenerateExec should be" +
     " included in WholeStageCodegen") { codegenEnabled =>
     import testImplicits._
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/benchmark/SortAggregateBenchmark.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/benchmark/SortAggregateBenchmark.scala
new file mode 100644
index 000000000000..64928c239b7c
--- /dev/null
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/benchmark/SortAggregateBenchmark.scala
@@ -0,0 +1,151 @@
+/*
+ * 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.
+ */
+
+package org.apache.spark.sql.execution.benchmark
+
+import org.apache.spark.benchmark.Benchmark
+import org.apache.spark.sql.internal.SQLConf
+
+/**
+ * Benchmark to measure performance for sort-based aggregate, focusing on the 
whole-stage
+ * code-gen path. Hash-map based aggregation is intentionally excluded; both
+ * `spark.sql.execution.useHashAggregateExec` and 
`spark.sql.execution.useObjectHashAggregateExec`
+ * are disabled so the planner always picks
+ * [[org.apache.spark.sql.execution.aggregate.SortAggregateExec]].
+ *
+ * To run this benchmark:
+ * {{{
+ *   1. without sbt: bin/spark-submit --class <this class>
+ *      --jars <spark core test jar>,<spark catalyst test jar> <spark sql test 
jar>
+ *   2. build/sbt "sql/Test/runMain <this class>"
+ *   3. generate result: SPARK_GENERATE_BENCHMARK_FILES=1 build/sbt 
"sql/Test/runMain <this class>"
+ *      Results will be written to 
"benchmarks/SortAggregateBenchmark-results.txt".
+ * }}}
+ */
+object SortAggregateBenchmark extends SqlBasedBenchmark {
+
+  // Force the planner to pick SortAggregateExec by disabling both hash-based 
aggregate operators.
+  private val forceSortAggregate: Map[String, String] = Map(
+    SQLConf.USE_HASH_AGG.key -> "false",
+    SQLConf.USE_OBJECT_HASH_AGG.key -> "false")
+
+  /**
+   * Adds the two cases we care about for a sort aggregate. Whole-stage 
code-gen stays enabled in
+   * both so the child pipeline (scan, sort) is code-gen'd either way; only 
the sort aggregate's own
+   * code-gen is toggled via `ENABLE_SORT_AGGREGATE_CODEGEN`, which isolates 
its contribution:
+   *  - code-gen off: sort aggregate falls back to the interpreted 
`SortBasedAggregationIterator`;
+   *  - code-gen on: code-gen'd `SortAggregateExec`.
+   */
+  private def addGroupingKeyCases(benchmark: Benchmark)(f: () => Unit): Unit = 
{
+    benchmark.addCase("codegen = F", numIters = 2) { _ =>
+      withSQLConf(
+        (forceSortAggregate ++ Map(
+          SQLConf.WHOLESTAGE_CODEGEN_ENABLED.key -> "true",
+          SQLConf.ENABLE_SORT_AGGREGATE_CODEGEN.key -> "false")).toSeq: _*) {
+        f()
+      }
+    }
+
+    benchmark.addCase("codegen = T", numIters = 5) { _ =>
+      withSQLConf(
+        (forceSortAggregate ++ Map(
+          SQLConf.WHOLESTAGE_CODEGEN_ENABLED.key -> "true",
+          SQLConf.ENABLE_SORT_AGGREGATE_CODEGEN.key -> "true")).toSeq: _*) {
+        f()
+      }
+    }
+  }
+
+  override def runBenchmarkSuite(mainArgs: Array[String]): Unit = {
+    runBenchmark("sort aggregate without grouping") {
+      val N = 500L << 20
+      val benchmark = new Benchmark("sort agg w/o group", N, output = output)
+      addGroupingKeyCases(benchmark) { () =>
+        spark.range(N).selectExpr("sum(id)").noop()
+      }
+      benchmark.run()
+    }
+
+    runBenchmark("sort aggregate with linear keys") {
+      val N = 20 << 22
+      val benchmark = new Benchmark("sort agg w linear keys", N, output = 
output)
+      // The child of a sort aggregate must be sorted by the grouping keys. 
The temp view is lazy,
+      // so the sort re-executes on every iteration (and without the explicit 
`sortWithinPartitions`
+      // the planner would insert an equivalent `SortExec` below the aggregate 
anyway); pinning the
+      // sort here just makes both cases pay the same sort cost, isolating the 
aggregate's own
+      // contribution. This means the reported speedup understates the 
aggregate-only gain.
+      spark.range(N).selectExpr("id", "(id & 65535) as k")
+        .sortWithinPartitions("k").createOrReplaceTempView("linear")
+      addGroupingKeyCases(benchmark) { () =>
+        spark.sql("select k, count(*), sum(id), max(id) from linear group by 
k").noop()
+      }
+      benchmark.run()
+    }
+
+    runBenchmark("sort aggregate with randomized keys") {
+      val N = 20 << 22
+      val benchmark = new Benchmark("sort agg w randomized keys", N, output = 
output)
+      spark.range(N).selectExpr("id", "floor(rand() * 10000) as k")
+        .sortWithinPartitions("k").createOrReplaceTempView("rand_keys")
+      addGroupingKeyCases(benchmark) { () =>
+        spark.sql("select k, count(*), sum(id), max(id) from rand_keys group 
by k").noop()
+      }
+      benchmark.run()
+    }
+
+    runBenchmark("sort aggregate with string key") {
+      val N = 20 << 20
+      val benchmark = new Benchmark("sort agg w string key", N, output = 
output)
+      spark.range(N).selectExpr("id", "cast(id & 1023 as string) as k")
+        .sortWithinPartitions("k").createOrReplaceTempView("string_key")
+      addGroupingKeyCases(benchmark) { () =>
+        spark.sql("select k, count(*), sum(id), max(id) from string_key group 
by k").noop()
+      }
+      benchmark.run()
+    }
+
+    runBenchmark("sort aggregate with decimal key") {
+      val N = 20 << 20
+      val benchmark = new Benchmark("sort agg w decimal key", N, output = 
output)
+      spark.range(N).selectExpr("id", "cast(id & 65535 as decimal(18, 0)) as 
k")
+        .sortWithinPartitions("k").createOrReplaceTempView("decimal_key")
+      addGroupingKeyCases(benchmark) { () =>
+        spark.sql("select k, count(*), sum(id), max(id) from decimal_key group 
by k").noop()
+      }
+      benchmark.run()
+    }
+
+    runBenchmark("sort aggregate with multiple key types") {
+      val N = 20 << 20
+      val benchmark = new Benchmark("sort agg w multiple keys", N, output = 
output)
+      spark.range(N)
+        .selectExpr(
+          "id",
+          "(id & 1023) as k1",
+          "cast(id & 1023 as string) as k2",
+          "cast(id & 1023 as int) as k3",
+          "id > 1023 as k4")
+        .sortWithinPartitions("k1", "k2", "k3", "k4")
+        .createOrReplaceTempView("multi_keys")
+      addGroupingKeyCases(benchmark) { () =>
+        spark.sql("select k1, k2, k3, k4, count(*), sum(id), max(id) " +
+          "from multi_keys group by k1, k2, k3, k4").noop()
+      }
+      benchmark.run()
+    }
+  }
+}


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

Reply via email to