From 5b4d4682814d3da1f499e2eb334725678dbece4f Mon Sep 17 00:00:00 2001 From: zhenglong li Date: Sat, 15 Aug 2026 13:55:00 +0800 Subject: [PATCH v1] Consider window functions when reordering GROUP BY items preprocess_groupclause() reorders the GROUP BY items to match the query's ORDER BY clause so that a single sort operation can serve both the grouping step and the final ordering. However, when the query contains window functions, the sort performed directly above the grouping step is the one required by the first window clause, not the one for ORDER BY, which is only performed above the WindowAgg nodes. Unless the window's sort requirements happened to coincide with a prefix of the ORDER BY clause, an additional sort was needed to provide the first WindowAgg with correctly ordered input. Teach preprocess_groupclause() to instead match the GROUP BY items against the first active window's PARTITION BY and ORDER BY keys when there are window functions, then against the query's ORDER BY clause for any remaining items. We match the first window only because it alone can share a sort with the grouping step: select_active_windows() has already fixed the windows' evaluation order by this point, placing the window with the strongest sort requirements first, directly above the grouping step. Satisfying its requirements may satisfy subsequent windows too, since windows whose requirements form a prefix of another's are evaluated later. For the same reason, if the first window requires no sort at all then no active window does, and matching the ORDER BY clause remains as useful as it is without window functions. Appending the query's ORDER BY keys after the window's keys orders the otherwise-arbitrary tail of the GROUP BY items. Since WindowAgg nodes preserve their input ordering, this can additionally save the final sort, and it retains the previous behavior for queries where the window's sort requirements form a prefix of the ORDER BY clause, which the ORDER BY matching alone already handled well. For example, the query SELECT a, b, count(*) OVER (PARTITION BY b) FROM t GROUP BY a, b; previously sorted twice: once by (a, b) for the grouping step and again by (b) for the WindowAgg. Reordering the GROUP BY items to (b, a) allows a single sort to serve both. With an ORDER BY that does not lead with the window's sort keys, such as adding "ORDER BY a" to the query above, the sort count similarly drops from three to two. Note that, like the long-standing ORDER BY matching, this heuristic is insensitive to column cardinalities, as is the sort cost model. Moving a low-cardinality window key to the front of a large grouping sort defeats the leading-key specializations in tuplesort and can cost more than the eliminated sort saved when the number of groups is small relative to the input. This is mitigated in practice because a small number of groups is also when hash aggregation tends to win, whereas sorted aggregation tends to be chosen when groups are numerous, which is exactly when the eliminated sort is large. Choosing the ordering based on cost is left for future work in get_useful_group_keys_orderings(). The preprocessing of a plain GROUP BY clause is moved until after select_active_windows() has determined the evaluation order of the windows. This is safe since nothing in between examines root->processed_groupClause. --- src/backend/optimizer/path/pathkeys.c | 4 +- src/backend/optimizer/plan/planner.c | 82 +++++++++++++---- src/test/regress/expected/window.out | 123 ++++++++++++++++++++++++++ src/test/regress/sql/window.sql | 61 +++++++++++++ 4 files changed, 250 insertions(+), 20 deletions(-) diff --git a/src/backend/optimizer/path/pathkeys.c b/src/backend/optimizer/path/pathkeys.c index 5eb71635d15..2da4bed70a4 100644 --- a/src/backend/optimizer/path/pathkeys.c +++ b/src/backend/optimizer/path/pathkeys.c @@ -459,8 +459,8 @@ group_keys_reorder_by_pathkeys(List *pathkeys, List **group_pathkeys, * * The function considers (and keeps) following GROUP BY orderings: * - * - GROUP BY keys as ordered by preprocess_groupclause() to match target - * ORDER BY clause (as much as possible), + * - GROUP BY keys as ordered by preprocess_groupclause() to match the target + * ORDER BY clause or the first window's sort order (as much as possible), * - GROUP BY keys reordered to match 'path' ordering (as much as possible). */ List * diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c index a0ff9159ae0..1ba27ad1914 100644 --- a/src/backend/optimizer/plan/planner.c +++ b/src/backend/optimizer/plan/planner.c @@ -159,7 +159,8 @@ static void preprocess_rowmarks(PlannerInfo *root); static double preprocess_limit(PlannerInfo *root, double tuple_fraction, int64 *offset_est, int64 *count_est); -static List *preprocess_groupclause(PlannerInfo *root, List *force); +static List *preprocess_groupclause(PlannerInfo *root, List *force, + List *activeWindows); static List *extract_rollup_sets(List *groupingSets); static List *reorder_grouping_sets(List *groupingSets, List *sortclause); static void standard_qp_callback(PlannerInfo *root, void *extra); @@ -1818,16 +1819,9 @@ grouping_planner(PlannerInfo *root, double tuple_fraction, /* A recursive query should always have setOperations */ Assert(!root->hasRecursion); - /* Preprocess grouping sets and GROUP BY clause, if any */ + /* Preprocess grouping sets, if any */ if (parse->groupingSets) - { gset_data = preprocess_grouping_sets(root); - } - else if (parse->groupClause) - { - /* Preprocess regular GROUP BY clause, if any */ - root->processed_groupClause = preprocess_groupclause(root, NIL); - } /* * Preprocess targetlist. Note that much of the remaining planning @@ -1880,6 +1874,17 @@ grouping_planner(PlannerInfo *root, double tuple_fraction, parse->hasWindowFuncs = false; } + /* + * Preprocess a plain GROUP BY clause, if any. We do this after + * selecting the active windows so that preprocess_groupclause can try + * to match the ordering of the GROUP BY elements to the sort order + * required by the first window, if there is one. (Grouping sets were + * already handled by preprocess_grouping_sets, above.) + */ + if (parse->groupClause && !parse->groupingSets) + root->processed_groupClause = preprocess_groupclause(root, NIL, + activeWindows); + /* * Preprocess MIN/MAX aggregates, if any. Note: be careful about * adding logic between here and the query_planner() call. Anything @@ -2578,7 +2583,7 @@ preprocess_grouping_sets(PlannerInfo *root) * The groupClauses for hashed grouping sets are built later on.) */ if (gs->set) - rollup->groupClause = preprocess_groupclause(root, gs->set); + rollup->groupClause = preprocess_groupclause(root, gs->set, NIL); else rollup->groupClause = NIL; @@ -3079,6 +3084,16 @@ limit_needed(Query *parse) * We also consider partial match between GROUP BY and ORDER BY elements, * which could allow to implement ORDER BY using the incremental sort. * + * When the query contains window functions, the sort performed directly + * above the grouping step is the one required by the first window clause + * rather than the one for ORDER BY (which is performed above the window + * functions). In that case we try to match the sort order required by the + * first of the 'activeWindows' (ie its PARTITION BY keys followed by its + * ORDER BY keys) first, and then the query's ORDER BY clause, since the + * WindowAgg steps may preserve the grouping step's output ordering up to + * the final sort. Callers passing a non-NIL 'force' list need not bother + * passing activeWindows. + * * We also consider other orderings of the GROUP BY elements, which could * match the sort ordering of other possible plans (eg an indexscan) and * thereby reduce cost. This is implemented during the generation of grouping @@ -3097,10 +3112,11 @@ limit_needed(Query *parse) * possible is done elsewhere. */ static List * -preprocess_groupclause(PlannerInfo *root, List *force) +preprocess_groupclause(PlannerInfo *root, List *force, List *activeWindows) { Query *parse = root->parse; List *new_groupclause = NIL; + List *matchClause; ListCell *sl; ListCell *gl; @@ -3118,17 +3134,47 @@ preprocess_groupclause(PlannerInfo *root, List *force) return new_groupclause; } - /* If no ORDER BY, nothing useful to do here */ - if (parse->sortClause == NIL) + /* + * Select the list of sort clauses that we'll try to match the GROUP BY + * elements to. Without window functions, this is simply the ORDER BY + * clause. + * + * If there are any active windows, the sort directly above the grouping + * step is the one for the first window, so match its PARTITION BY keys + * followed by its ORDER BY keys first. As in select_active_windows(), + * remove any entries that duplicate earlier ones. + * + * We then append the query's ORDER BY clause. The ordering of any GROUP + * BY items beyond the first window's sort requirements is otherwise + * arbitrary, and since the WindowAgg steps preserve their input ordering + * (as long as the remaining windows require no additional sorts, which + * is the case whenever their sort requirements are prefixes of the first + * window's, per select_active_windows()), making the tail match the + * ORDER BY can save the final sort too. This also covers the case where + * no active window requires any sort at all. + */ + if (activeWindows != NIL) + { + WindowClause *wc = linitial_node(WindowClause, activeWindows); + + matchClause = list_concat_unique(list_copy(wc->partitionClause), + wc->orderClause); + matchClause = list_concat_unique(matchClause, parse->sortClause); + } + else + matchClause = parse->sortClause; + + /* If nothing to match against, nothing useful to do here */ + if (matchClause == NIL) return list_copy(parse->groupClause); /* - * Scan the ORDER BY clause and construct a list of matching GROUP BY + * Scan the clauses to match and construct a list of matching GROUP BY * items, but only as far as we can make a matching prefix. * - * This code assumes that the sortClause contains no duplicate items. + * This code assumes that the list contains no duplicate items. */ - foreach(sl, parse->sortClause) + foreach(sl, matchClause) { SortGroupClause *sc = lfirst_node(SortGroupClause, sl); @@ -4554,7 +4600,7 @@ consider_groupingsets_paths(PlannerInfo *root, { rollup = makeNode(RollupData); - rollup->groupClause = preprocess_groupclause(root, gset); + rollup->groupClause = preprocess_groupclause(root, gset, NIL); rollup->gsets_data = list_make1(gs); rollup->gsets = remap_to_groupclause_idx(rollup->groupClause, rollup->gsets_data, @@ -4743,7 +4789,7 @@ consider_groupingsets_paths(PlannerInfo *root, Assert(gs->set != NIL); - rollup->groupClause = preprocess_groupclause(root, gs->set); + rollup->groupClause = preprocess_groupclause(root, gs->set, NIL); rollup->gsets_data = list_make1(gs); rollup->gsets = remap_to_groupclause_idx(rollup->groupClause, rollup->gsets_data, diff --git a/src/test/regress/expected/window.out b/src/test/regress/expected/window.out index c0bde1c5eec..1d67040b798 100644 --- a/src/test/regress/expected/window.out +++ b/src/test/regress/expected/window.out @@ -4839,6 +4839,129 @@ WHERE first_emp = 1 OR last_emp = 1; sales | 4 | 4800 | 08-08-2007 | 3 | 1 (6 rows) +-- Test reordering of the GROUP BY keys to match the first window's sort +-- order +SET enable_hashagg TO off; +-- Ensure the GROUP BY keys are reordered to match the sort order of the +-- window's PARTITION BY clause, allowing the sort for the grouping step to +-- also provide the sorted input required by the WindowAgg. +EXPLAIN (COSTS OFF) +SELECT empno, depname, min(salary) minsalary, + sum(min(salary)) OVER (PARTITION BY depname) depminsum +FROM empsalary +GROUP BY empno, depname; + QUERY PLAN +----------------------------------------- + WindowAgg + Window: w1 AS (PARTITION BY depname) + -> GroupAggregate + Group Key: depname, empno + -> Sort + Sort Key: depname, empno + -> Seq Scan on empsalary +(7 rows) + +-- As above, but include the window's ORDER BY keys too. +EXPLAIN (COSTS OFF) +SELECT empno, depname, + count(*) OVER (PARTITION BY depname ORDER BY empno) c +FROM empsalary +GROUP BY empno, depname; + QUERY PLAN +------------------------------------------------------- + WindowAgg + Window: w1 AS (PARTITION BY depname ORDER BY empno) + -> Group + Group Key: depname, empno + -> Sort + Sort Key: depname, empno + -> Seq Scan on empsalary +(7 rows) + +-- Ensure the GROUP BY keys are matched to the window's sort order in +-- preference to the query's ORDER BY, since the latter sort is performed +-- above the WindowAgg. +EXPLAIN (COSTS OFF) +SELECT empno, depname, min(salary) minsalary, + sum(min(salary)) OVER (PARTITION BY depname) depminsum +FROM empsalary +GROUP BY empno, depname +ORDER BY empno; + QUERY PLAN +----------------------------------------------- + Sort + Sort Key: empno + -> WindowAgg + Window: w1 AS (PARTITION BY depname) + -> GroupAggregate + Group Key: depname, empno + -> Sort + Sort Key: depname, empno + -> Seq Scan on empsalary +(9 rows) + +-- Ensure the GROUP BY keys remaining after matching the window's sort order +-- are ordered to match the query's ORDER BY, allowing the final sort to be +-- avoided when the WindowAgg preserves the grouping step's output ordering. +EXPLAIN (COSTS OFF) +SELECT empno, depname, min(salary) minsalary, + sum(min(salary)) OVER (PARTITION BY depname) depminsum +FROM empsalary +GROUP BY empno, enroll_date, depname +ORDER BY depname, enroll_date; + QUERY PLAN +----------------------------------------------------- + WindowAgg + Window: w1 AS (PARTITION BY depname) + -> GroupAggregate + Group Key: depname, enroll_date, empno + -> Sort + Sort Key: depname, enroll_date, empno + -> Seq Scan on empsalary +(7 rows) + +-- As above, but with an ORDER BY that does not lead with the window's sort +-- keys. Ensure the remaining GROUP BY keys still follow the query's ORDER +-- BY even though the final sort cannot be avoided. +EXPLAIN (COSTS OFF) +SELECT empno, depname, min(salary) minsalary, + sum(min(salary)) OVER (PARTITION BY depname) depminsum +FROM empsalary +GROUP BY empno, enroll_date, depname +ORDER BY enroll_date, empno; + QUERY PLAN +----------------------------------------------------------- + Sort + Sort Key: enroll_date, empno + -> WindowAgg + Window: w1 AS (PARTITION BY depname) + -> GroupAggregate + Group Key: depname, enroll_date, empno + -> Sort + Sort Key: depname, enroll_date, empno + -> Seq Scan on empsalary +(9 rows) + +-- Ensure the GROUP BY keys are still matched to the query's ORDER BY when +-- the window imposes no sort order of its own. +EXPLAIN (COSTS OFF) +SELECT empno, depname, min(salary) minsalary, + sum(min(salary)) OVER () salarysum +FROM empsalary +GROUP BY depname, empno +ORDER BY empno, depname; + QUERY PLAN +----------------------------------------- + WindowAgg + Window: w1 AS () + -> GroupAggregate + Group Key: empno, depname + -> Sort + Sort Key: empno, depname + -> Seq Scan on empsalary +(7 rows) + +RESET enable_hashagg; CREATE INDEX empsalary_salary_empno_idx ON empsalary (salary, empno); SET enable_seqscan = 0; -- Ensure no sorting is done and that the IndexScan maintains all pathkeys diff --git a/src/test/regress/sql/window.sql b/src/test/regress/sql/window.sql index 8e6f92d94c7..e23046b589b 100644 --- a/src/test/regress/sql/window.sql +++ b/src/test/regress/sql/window.sql @@ -1659,6 +1659,67 @@ SELECT * FROM FROM empsalary) emp WHERE first_emp = 1 OR last_emp = 1; +-- Test reordering of the GROUP BY keys to match the first window's sort +-- order +SET enable_hashagg TO off; + +-- Ensure the GROUP BY keys are reordered to match the sort order of the +-- window's PARTITION BY clause, allowing the sort for the grouping step to +-- also provide the sorted input required by the WindowAgg. +EXPLAIN (COSTS OFF) +SELECT empno, depname, min(salary) minsalary, + sum(min(salary)) OVER (PARTITION BY depname) depminsum +FROM empsalary +GROUP BY empno, depname; + +-- As above, but include the window's ORDER BY keys too. +EXPLAIN (COSTS OFF) +SELECT empno, depname, + count(*) OVER (PARTITION BY depname ORDER BY empno) c +FROM empsalary +GROUP BY empno, depname; + +-- Ensure the GROUP BY keys are matched to the window's sort order in +-- preference to the query's ORDER BY, since the latter sort is performed +-- above the WindowAgg. +EXPLAIN (COSTS OFF) +SELECT empno, depname, min(salary) minsalary, + sum(min(salary)) OVER (PARTITION BY depname) depminsum +FROM empsalary +GROUP BY empno, depname +ORDER BY empno; + +-- Ensure the GROUP BY keys remaining after matching the window's sort order +-- are ordered to match the query's ORDER BY, allowing the final sort to be +-- avoided when the WindowAgg preserves the grouping step's output ordering. +EXPLAIN (COSTS OFF) +SELECT empno, depname, min(salary) minsalary, + sum(min(salary)) OVER (PARTITION BY depname) depminsum +FROM empsalary +GROUP BY empno, enroll_date, depname +ORDER BY depname, enroll_date; + +-- As above, but with an ORDER BY that does not lead with the window's sort +-- keys. Ensure the remaining GROUP BY keys still follow the query's ORDER +-- BY even though the final sort cannot be avoided. +EXPLAIN (COSTS OFF) +SELECT empno, depname, min(salary) minsalary, + sum(min(salary)) OVER (PARTITION BY depname) depminsum +FROM empsalary +GROUP BY empno, enroll_date, depname +ORDER BY enroll_date, empno; + +-- Ensure the GROUP BY keys are still matched to the query's ORDER BY when +-- the window imposes no sort order of its own. +EXPLAIN (COSTS OFF) +SELECT empno, depname, min(salary) minsalary, + sum(min(salary)) OVER () salarysum +FROM empsalary +GROUP BY depname, empno +ORDER BY empno, depname; + +RESET enable_hashagg; + CREATE INDEX empsalary_salary_empno_idx ON empsalary (salary, empno); SET enable_seqscan = 0; -- 2.43.0