From d23e670a0725b03352dc1bbcc6add5dfc0e7819d Mon Sep 17 00:00:00 2001
From: Chengpeng Yan <chengpeng_yan@outlook.com>
Date: Sat, 7 Feb 2026 20:21:27 +0800
Subject: [PATCH v2] planner: postpone some non-sort output expressions past
 Sort

Allow make_sort_input_target() to postpone additional non-sort
targetlist expressions when doing so doesn't require carrying any
additional Vars or PlaceHolderVars through the Sort.  This keeps the
sort input no wider, and can avoid evaluating output expressions for
rows that are never returned under LIMIT.
---
 .../postgres_fdw/expected/postgres_fdw.out    |   6 +-
 src/backend/optimizer/plan/planner.c          | 121 +++++++++++++++++-
 src/test/regress/expected/groupingsets.out    |  99 +++++++-------
 src/test/regress/expected/join.out            |   9 +-
 src/test/regress/expected/limit.out           |  51 ++++++++
 src/test/regress/expected/tuplesort.out       |  42 ++++++
 src/test/regress/sql/limit.sql                |  26 ++++
 src/test/regress/sql/tuplesort.sql            |  20 +++
 8 files changed, 318 insertions(+), 56 deletions(-)

diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index 7cad5e67d09..2d52820888a 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -3073,12 +3073,12 @@ select c2 * (random() <= 1)::int as c2 from ft2 group by c2 * (random() <= 1)::i
 -- GROUP BY clause in various forms, cardinal, alias and constant expression
 explain (verbose, costs off)
 select count(c2) w, c2 x, 5 y, 7.0 z from ft1 group by 2, y, 9.0::int order by 2;
-                                                QUERY PLAN                                                 
------------------------------------------------------------------------------------------------------------
+                                              QUERY PLAN                                              
+------------------------------------------------------------------------------------------------------
  Foreign Scan
    Output: (count(*)), c2, 5, 7.0, 9
    Relations: Aggregate on (public.ft1)
-   Remote SQL: SELECT count(*), c2, 5, 7.0, 9 FROM "S 1"."T 1" GROUP BY 2, 3, 5 ORDER BY c2 ASC NULLS LAST
+   Remote SQL: SELECT count(*), c2, 5, 9 FROM "S 1"."T 1" GROUP BY 2, 3, 4 ORDER BY c2 ASC NULLS LAST
 (4 rows)
 
 select count(c2) w, c2 x, 5 y, 7.0 z from ft1 group by 2, y, 9.0::int order by 2;
diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index f68142cfcb8..5ba5900f1aa 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -6447,6 +6447,11 @@ make_pathkeys_for_window(PlannerInfo *root, WindowClause *wc,
  * any volatile or set-returning expressions (since once we've put in a
  * projection at all, it won't cost any more to postpone more stuff).
  *
+ * We can also postpone some additional non-sort output expressions when doing
+ * so would not require carrying any additional Vars/PlaceHolderVars through
+ * the Sort.  This keeps the sort input no wider, and can avoid evaluating
+ * output expressions for rows that are never returned under LIMIT.
+ *
  * Another issue that could potentially be considered here is that
  * evaluating tlist expressions could result in data that's either wider
  * or narrower than the input Vars, thus changing the volume of data that
@@ -6487,12 +6492,14 @@ make_sort_input_target(PlannerInfo *root,
 	PathTarget *input_target;
 	int			ncols;
 	bool	   *col_is_srf;
+	bool	   *col_is_expensive;
 	bool	   *postpone_col;
 	bool		have_srf;
 	bool		have_volatile;
 	bool		have_expensive;
 	bool		have_srf_sortcols;
 	bool		postpone_srfs;
+	bool		have_safe_postpone;
 	List	   *postponable_cols;
 	List	   *postponable_vars;
 	int			i;
@@ -6506,8 +6513,10 @@ make_sort_input_target(PlannerInfo *root,
 	/* Inspect tlist and collect per-column information */
 	ncols = list_length(final_target->exprs);
 	col_is_srf = (bool *) palloc0(ncols * sizeof(bool));
+	col_is_expensive = (bool *) palloc0(ncols * sizeof(bool));
 	postpone_col = (bool *) palloc0(ncols * sizeof(bool));
 	have_srf = have_volatile = have_expensive = have_srf_sortcols = false;
+	have_safe_postpone = false;
 
 	i = 0;
 	foreach(lc, final_target->exprs)
@@ -6559,7 +6568,7 @@ make_sort_input_target(PlannerInfo *root,
 				 */
 				if (cost.per_tuple > 10 * cpu_operator_cost)
 				{
-					postpone_col[i] = true;
+					col_is_expensive[i] = true;
 					have_expensive = true;
 				}
 			}
@@ -6581,12 +6590,120 @@ make_sort_input_target(PlannerInfo *root,
 	 */
 	postpone_srfs = (have_srf && !have_srf_sortcols);
 
+	/*
+	 * Keep the historical expensive-expression policy: once we're adding a
+	 * post-sort projection for any reason, postpone all expensive
+	 * expressions.
+	 */
+	if (postpone_srfs || have_volatile ||
+		(have_expensive && (parse->limitCount || root->tuple_fraction > 0)))
+	{
+		i = 0;
+		foreach(lc, final_target->exprs)
+		{
+			if (col_is_expensive[i])
+				postpone_col[i] = true;
+			i++;
+		}
+	}
+
+	/*
+	 * We can postpone some additional non-sort output expressions if doing so
+	 * doesn't require carrying any extra Vars/PlaceHolderVars through the
+	 * Sort.
+	 */
+	{
+		List	   *required_exprs = NIL;
+		List	   *base_postponable_cols = NIL;
+		List	   *base_postponable_vars;
+		List	   *required_before_sort;
+		int			j;
+
+		/*
+		 * Build the set of expressions that will already be carried through
+		 * the Sort: non-postponed columns, plus Vars/PHVs etc needed for
+		 * already-postponed columns.
+		 */
+		j = 0;
+		foreach(lc, final_target->exprs)
+		{
+			Expr	   *expr = (Expr *) lfirst(lc);
+
+			if (postpone_col[j] || (postpone_srfs && col_is_srf[j]))
+				base_postponable_cols = lappend(base_postponable_cols, expr);
+			else
+				required_exprs = lappend(required_exprs, expr);
+
+			j++;
+		}
+
+		base_postponable_vars = pull_var_clause((Node *) base_postponable_cols,
+												PVC_INCLUDE_AGGREGATES |
+												PVC_INCLUDE_WINDOWFUNCS |
+												PVC_INCLUDE_PLACEHOLDERS);
+		required_before_sort = list_union(required_exprs, base_postponable_vars);
+
+		/*
+		 * Mark any safe-to-postpone columns.  We ignore simple Vars/Aggrefs/
+		 * WindowFuncs/PHVs because postponing them would not avoid any work.
+		 */
+		j = 0;
+		foreach(lc, final_target->exprs)
+		{
+			Expr	   *expr = (Expr *) lfirst(lc);
+			List	   *expr_vars;
+			ListCell   *lc2;
+			bool		safe = true;
+
+			if (postpone_col[j] || (postpone_srfs && col_is_srf[j]) ||
+				col_is_srf[j] ||
+				get_pathtarget_sortgroupref(final_target, j) != 0 ||
+				IsA(expr, Var) ||
+				IsA(expr, Aggref) ||
+				IsA(expr, WindowFunc) ||
+				IsA(expr, PlaceHolderVar))
+			{
+				j++;
+				continue;
+			}
+
+			expr_vars = pull_var_clause((Node *) expr,
+										PVC_INCLUDE_AGGREGATES |
+										PVC_INCLUDE_WINDOWFUNCS |
+										PVC_INCLUDE_PLACEHOLDERS);
+			foreach(lc2, expr_vars)
+			{
+				if (!list_member(required_before_sort, lfirst(lc2)))
+				{
+					safe = false;
+					break;
+				}
+			}
+
+			if (safe)
+			{
+				postpone_col[j] = true;
+				have_safe_postpone = true;
+			}
+
+			list_free(expr_vars);
+			j++;
+		}
+
+		/* clean up cruft */
+		list_free(required_before_sort);
+		list_free(base_postponable_vars);
+		list_free(base_postponable_cols);
+		list_free(required_exprs);
+	}
+
 	/*
 	 * If we don't need a post-sort projection, just return final_target.
 	 */
 	if (!(postpone_srfs || have_volatile ||
 		  (have_expensive &&
-		   (parse->limitCount || root->tuple_fraction > 0))))
+		   (parse->limitCount || root->tuple_fraction > 0)) ||
+		  have_safe_postpone))
 		return final_target;
 
 	/*
diff --git a/src/test/regress/expected/groupingsets.out b/src/test/regress/expected/groupingsets.out
index 921017489c0..5d4759521a7 100644
--- a/src/test/regress/expected/groupingsets.out
+++ b/src/test/regress/expected/groupingsets.out
@@ -972,19 +972,20 @@ select v.c, (select count(*) from gstest2 group by () having v.c)
 explain (costs off)
   select v.c, (select count(*) from gstest2 group by () having v.c)
     from (values (false),(true)) v(c) order by v.c;
-                        QUERY PLAN                         
------------------------------------------------------------
- Sort
-   Sort Key: "*VALUES*".column1
-   ->  Values Scan on "*VALUES*"
-         SubPlan expr_1
-           ->  Aggregate
-                 Group Key: ()
-                 Filter: "*VALUES*".column1
-                 ->  Result
-                       One-Time Filter: "*VALUES*".column1
-                       ->  Seq Scan on gstest2
-(10 rows)
+                     QUERY PLAN                      
+-----------------------------------------------------
+ Result
+   ->  Sort
+         Sort Key: "*VALUES*".column1
+         ->  Values Scan on "*VALUES*"
+   SubPlan expr_1
+     ->  Aggregate
+           Group Key: ()
+           Filter: "*VALUES*".column1
+           ->  Result
+                 One-Time Filter: "*VALUES*".column1
+                 ->  Seq Scan on gstest2
+(11 rows)
 
 -- test pushdown of non-degenerate HAVING clause that does not reference any
 -- columns that are nullable by grouping sets
@@ -2398,24 +2399,26 @@ order by case when grouping((select t1.v from gstest5 t2 where id = t1.id)) = 0
               then (select t1.v from gstest5 t2 where id = t1.id)
               else null end
          nulls first;
-                                                                           QUERY PLAN                                                                            
------------------------------------------------------------------------------------------------------------------------------------------------------------------
- Sort
+                                                                              QUERY PLAN                                                                               
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------
+ Result
    Output: (GROUPING((SubPlan expr_1))), ((SubPlan expr_3)), (CASE WHEN (GROUPING((SubPlan expr_2)) = 0) THEN ((SubPlan expr_3)) ELSE NULL::integer END), t1.v
-   Sort Key: (CASE WHEN (GROUPING((SubPlan expr_2)) = 0) THEN ((SubPlan expr_3)) ELSE NULL::integer END) NULLS FIRST
-   ->  HashAggregate
-         Output: GROUPING((SubPlan expr_1)), ((SubPlan expr_3)), CASE WHEN (GROUPING((SubPlan expr_2)) = 0) THEN ((SubPlan expr_3)) ELSE NULL::integer END, t1.v
-         Hash Key: t1.v
-         Hash Key: (SubPlan expr_3)
-         ->  Seq Scan on pg_temp.gstest5 t1
-               Output: (SubPlan expr_3), t1.v, t1.id
-               SubPlan expr_3
-                 ->  Bitmap Heap Scan on pg_temp.gstest5 t2
-                       Output: t1.v
-                       Recheck Cond: (t2.id = t1.id)
-                       ->  Bitmap Index Scan on gstest5_pkey
-                             Index Cond: (t2.id = t1.id)
-(15 rows)
+   ->  Sort
+         Output: ((SubPlan expr_3)), (CASE WHEN (GROUPING((SubPlan expr_2)) = 0) THEN ((SubPlan expr_3)) ELSE NULL::integer END), t1.v, (GROUPING((SubPlan expr_1)))
+         Sort Key: (CASE WHEN (GROUPING((SubPlan expr_2)) = 0) THEN ((SubPlan expr_3)) ELSE NULL::integer END) NULLS FIRST
+         ->  HashAggregate
+               Output: ((SubPlan expr_3)), CASE WHEN (GROUPING((SubPlan expr_2)) = 0) THEN ((SubPlan expr_3)) ELSE NULL::integer END, t1.v, GROUPING((SubPlan expr_1))
+               Hash Key: t1.v
+               Hash Key: (SubPlan expr_3)
+               ->  Seq Scan on pg_temp.gstest5 t1
+                     Output: (SubPlan expr_3), t1.v, t1.id
+                     SubPlan expr_3
+                       ->  Bitmap Heap Scan on pg_temp.gstest5 t2
+                             Output: t1.v
+                             Recheck Cond: (t2.id = t1.id)
+                             ->  Bitmap Index Scan on gstest5_pkey
+                                   Index Cond: (t2.id = t1.id)
+(17 rows)
 
 select grouping((select t1.v from gstest5 t2 where id = t1.id)),
        (select t1.v from gstest5 t2 where id = t1.id) as s
@@ -2448,24 +2451,26 @@ select grouping((select t1.v from gstest5 t2 where id = t1.id)),
 from gstest5 t1
 group by grouping sets(v, s)
 order by o nulls first;
-                                                                           QUERY PLAN                                                                            
------------------------------------------------------------------------------------------------------------------------------------------------------------------
- Sort
+                                                                              QUERY PLAN                                                                               
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------
+ Result
    Output: (GROUPING((SubPlan expr_1))), ((SubPlan expr_3)), (CASE WHEN (GROUPING((SubPlan expr_2)) = 0) THEN ((SubPlan expr_3)) ELSE NULL::integer END), t1.v
-   Sort Key: (CASE WHEN (GROUPING((SubPlan expr_2)) = 0) THEN ((SubPlan expr_3)) ELSE NULL::integer END) NULLS FIRST
-   ->  HashAggregate
-         Output: GROUPING((SubPlan expr_1)), ((SubPlan expr_3)), CASE WHEN (GROUPING((SubPlan expr_2)) = 0) THEN ((SubPlan expr_3)) ELSE NULL::integer END, t1.v
-         Hash Key: t1.v
-         Hash Key: (SubPlan expr_3)
-         ->  Seq Scan on pg_temp.gstest5 t1
-               Output: (SubPlan expr_3), t1.v, t1.id
-               SubPlan expr_3
-                 ->  Bitmap Heap Scan on pg_temp.gstest5 t2
-                       Output: t1.v
-                       Recheck Cond: (t2.id = t1.id)
-                       ->  Bitmap Index Scan on gstest5_pkey
-                             Index Cond: (t2.id = t1.id)
-(15 rows)
+   ->  Sort
+         Output: ((SubPlan expr_3)), (CASE WHEN (GROUPING((SubPlan expr_2)) = 0) THEN ((SubPlan expr_3)) ELSE NULL::integer END), t1.v, (GROUPING((SubPlan expr_1)))
+         Sort Key: (CASE WHEN (GROUPING((SubPlan expr_2)) = 0) THEN ((SubPlan expr_3)) ELSE NULL::integer END) NULLS FIRST
+         ->  HashAggregate
+               Output: ((SubPlan expr_3)), CASE WHEN (GROUPING((SubPlan expr_2)) = 0) THEN ((SubPlan expr_3)) ELSE NULL::integer END, t1.v, GROUPING((SubPlan expr_1))
+               Hash Key: t1.v
+               Hash Key: (SubPlan expr_3)
+               ->  Seq Scan on pg_temp.gstest5 t1
+                     Output: (SubPlan expr_3), t1.v, t1.id
+                     SubPlan expr_3
+                       ->  Bitmap Heap Scan on pg_temp.gstest5 t2
+                             Output: t1.v
+                             Recheck Cond: (t2.id = t1.id)
+                             ->  Bitmap Index Scan on gstest5_pkey
+                                   Index Cond: (t2.id = t1.id)
+(17 rows)
 
 select grouping((select t1.v from gstest5 t2 where id = t1.id)),
        (select t1.v from gstest5 t2 where id = t1.id) as s,
diff --git a/src/test/regress/expected/join.out b/src/test/regress/expected/join.out
index d05a0ca0373..d1cc951aae3 100644
--- a/src/test/regress/expected/join.out
+++ b/src/test/regress/expected/join.out
@@ -2810,12 +2810,13 @@ select count(*) from
    ->  Merge Left Join
          Merge Cond: (x.thousand = y.unique2)
          Join Filter: ((x.twothousand = y.hundred) AND (x.fivethous = y.unique2))
-         ->  Sort
-               Sort Key: x.thousand, x.twothousand, x.fivethous
-               ->  Seq Scan on tenk1 x
+         ->  Result
+               ->  Sort
+                     Sort Key: x.thousand, x.twothousand, x.fivethous
+                     ->  Seq Scan on tenk1 x
          ->  Materialize
                ->  Index Scan using tenk1_unique2 on tenk1 y
-(9 rows)
+(10 rows)
 
 select count(*) from
   (select * from tenk1 x order by x.thousand, x.twothousand, x.fivethous) x
diff --git a/src/test/regress/expected/limit.out b/src/test/regress/expected/limit.out
index e3bcc680653..7e29f09c68f 100644
--- a/src/test/regress/expected/limit.out
+++ b/src/test/regress/expected/limit.out
@@ -555,6 +555,57 @@ select sum(tenthous) as s1, sum(tenthous) + random()*0 as s2
  45020 | 45020
 (3 rows)
 
+--
+-- Postpone non-sort output expressions past Sort under LIMIT, when doing so
+-- doesn't require carrying additional columns through the Sort.
+--
+explain (verbose, costs off)
+select repeat(g.i::text, 100)
+  from generate_series(1, 100) g(i)
+  order by g.i
+  limit 10;
+                           QUERY PLAN                            
+-----------------------------------------------------------------
+ Limit
+   Output: (repeat((i)::text, 100)), i
+   ->  Result
+         Output: repeat((i)::text, 100), i
+         ->  Sort
+               Output: i
+               Sort Key: g.i
+               ->  Function Scan on pg_catalog.generate_series g
+                     Output: i
+                     Function Call: generate_series(1, 100)
+(10 rows)
+
+--
+-- Don't postpone if that would require carrying a wide column through
+-- the Sort.  Use MATERIALIZED to prevent inlining.
+--
+explain (verbose, costs off)
+with s(i, wide) as materialized (
+  select i, repeat(i::text, 100) as wide
+    from generate_series(1, 100) g(i)
+)
+select md5(wide)
+  from s
+  order by i
+  limit 10;
+                      QUERY PLAN                       
+-------------------------------------------------------
+ Limit
+   Output: (md5(s.wide)), s.i
+   CTE s
+     ->  Function Scan on pg_catalog.generate_series g
+           Output: g.i, repeat((g.i)::text, 100)
+           Function Call: generate_series(1, 100)
+   ->  Sort
+         Output: (md5(s.wide)), s.i
+         Sort Key: s.i
+         ->  CTE Scan on s
+               Output: md5(s.wide), s.i
+(11 rows)
+
 --
 -- FETCH FIRST
 -- Check the WITH TIES clause
diff --git a/src/test/regress/expected/tuplesort.out b/src/test/regress/expected/tuplesort.out
index 6dd97e7427a..1c15c6b21fa 100644
--- a/src/test/regress/expected/tuplesort.out
+++ b/src/test/regress/expected/tuplesort.out
@@ -356,6 +356,48 @@ ORDER BY v.a DESC;
  aaaaaaaaaa | 1
 (2 rows)
 
+----
+-- Test postponing non-sort output expressions past Sort.
+----
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT repeat(g.i::text, 100)
+  FROM generate_series(1, 100) g(i)
+  ORDER BY g.i;
+                        QUERY PLAN                         
+-----------------------------------------------------------
+ Result
+   Output: repeat((i)::text, 100), i
+   ->  Sort
+         Output: i
+         Sort Key: g.i
+         ->  Function Scan on pg_catalog.generate_series g
+               Output: i
+               Function Call: generate_series(1, 100)
+(8 rows)
+
+-- Don't postpone if that would require carrying a wide column through
+-- the sort.  Use MATERIALIZED to prevent inlining.
+EXPLAIN (VERBOSE, COSTS OFF)
+WITH s(i, wide) AS MATERIALIZED (
+  SELECT i, repeat(i::text, 100) AS wide
+    FROM generate_series(1, 100) g(i)
+)
+SELECT md5(wide)
+  FROM s
+  ORDER BY i;
+                      QUERY PLAN                       
+-------------------------------------------------------
+ Sort
+   Output: (md5(s.wide)), s.i
+   Sort Key: s.i
+   CTE s
+     ->  Function Scan on pg_catalog.generate_series g
+           Output: g.i, repeat((g.i)::text, 100)
+           Function Call: generate_series(1, 100)
+   ->  CTE Scan on s
+         Output: md5(s.wide), s.i
+(9 rows)
+
 ----
 -- test forward and backward scans for in-memory and disk based tuplesort
 ----
diff --git a/src/test/regress/sql/limit.sql b/src/test/regress/sql/limit.sql
index 603910fe6d1..41323fbda1e 100644
--- a/src/test/regress/sql/limit.sql
+++ b/src/test/regress/sql/limit.sql
@@ -152,6 +152,32 @@ select sum(tenthous) as s1, sum(tenthous) + random()*0 as s2
 select sum(tenthous) as s1, sum(tenthous) + random()*0 as s2
   from tenk1 group by thousand order by thousand limit 3;
 
+--
+-- Postpone non-sort output expressions past Sort under LIMIT, when doing so
+-- doesn't require carrying additional columns through the Sort.
+--
+
+explain (verbose, costs off)
+select repeat(g.i::text, 100)
+  from generate_series(1, 100) g(i)
+  order by g.i
+  limit 10;
+
+--
+-- Don't postpone if that would require carrying a wide column through
+-- the Sort.  Use MATERIALIZED to prevent inlining.
+--
+
+explain (verbose, costs off)
+with s(i, wide) as materialized (
+  select i, repeat(i::text, 100) as wide
+    from generate_series(1, 100) g(i)
+)
+select md5(wide)
+  from s
+  order by i
+  limit 10;
+
 --
 -- FETCH FIRST
 -- Check the WITH TIES clause
diff --git a/src/test/regress/sql/tuplesort.sql b/src/test/regress/sql/tuplesort.sql
index 8476e594e6c..d63ec576c73 100644
--- a/src/test/regress/sql/tuplesort.sql
+++ b/src/test/regress/sql/tuplesort.sql
@@ -155,6 +155,26 @@ SELECT LEFT(a,10),b FROM
     (VALUES(REPEAT('a', 512 * 1024),1),(REPEAT('b', 512 * 1024),2)) v(a,b)
 ORDER BY v.a DESC;
 
+----
+-- Test postponing non-sort output expressions past Sort.
+----
+
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT repeat(g.i::text, 100)
+  FROM generate_series(1, 100) g(i)
+  ORDER BY g.i;
+
+-- Don't postpone if that would require carrying a wide column through
+-- the sort.  Use MATERIALIZED to prevent inlining.
+EXPLAIN (VERBOSE, COSTS OFF)
+WITH s(i, wide) AS MATERIALIZED (
+  SELECT i, repeat(i::text, 100) AS wide
+    FROM generate_series(1, 100) g(i)
+)
+SELECT md5(wide)
+  FROM s
+  ORDER BY i;
+
 ----
 -- test forward and backward scans for in-memory and disk based tuplesort
 ----
-- 
2.50.1 (Apple Git-155)

