From 963c206ae335371a79d8cf552225b830eb353893 Mon Sep 17 00:00:00 2001
From: William Bernbaum <wbernbaum@dwdev.com>
Date: Tue, 1 Sep 2026 01:06:44 -0700
Subject: [PATCH] Price hashed aggregation for hash tables that exceed CPU
 cache capacity

cost_agg() charges cpu_operator_cost per grouping column for hashing each
input tuple, but does not charge anything for the corresponding table
probe.  Measured cost per input tuple grows with hash table size, because
an increasing fraction of probes miss the CPU cache.  Add
effective_cpu_cache_size and charge each input tuple per doubling
of the resident hash table.
---
 doc/src/sgml/config.sgml                      | 29 ++++++++++++
 src/backend/optimizer/path/costsize.c         | 45 +++++++++++++++++++
 src/backend/utils/misc/guc_parameters.dat     | 10 +++++
 src/backend/utils/misc/postgresql.conf.sample |  1 +
 src/include/optimizer/cost.h                  |  1 +
 src/include/optimizer/optimizer.h             |  1 +
 6 files changed, 87 insertions(+)

diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml
index 0165eb9ec02..c79cb536da5 100644
--- a/doc/src/sgml/config.sgml
+++ b/doc/src/sgml/config.sgml
@@ -6542,6 +6542,35 @@ ANY <replaceable class="parameter">num_sync</replaceable> ( <replaceable class="
       </listitem>
      </varlistentry>
 
+     <varlistentry id="guc-effective-cpu-cache-size" xreflabel="effective_cpu_cache_size">
+      <term><varname>effective_cpu_cache_size</varname> (<type>integer</type>)
+      <indexterm>
+       <primary><varname>effective_cpu_cache_size</varname> configuration parameter</primary>
+      </indexterm>
+      </term>
+      <listitem>
+       <para>
+        Sets the planner's assumption about how much of a hash table stays in
+        the <acronym>CPU</acronym>'s data cache.  Hashed aggregation is charged
+        for the additional memory traffic of a hash table larger than this, so
+        a higher value makes hashed aggregation more likely to be chosen over
+        sorted aggregation for queries with many groups, and a lower value less
+        likely.  Setting it to <literal>0</literal> removes the extra cost
+        entirely.
+       </para>
+
+       <para>
+        Set this to the cache available to a single query, which is
+        smaller than the machine's total last-level cache when several queries
+        or parallel workers run concurrently, each building its own hash table.
+        This parameter is used only for estimation purposes; it neither
+        reserves cache nor limits the size of any hash table.
+        If this value is specified without units, it is taken as kilobytes.
+        The default is 8 megabytes (<literal>8MB</literal>).
+       </para>
+      </listitem>
+     </varlistentry>
+
      <varlistentry id="guc-min-eager-agg-group-size" xreflabel="min_eager_agg_group_size">
       <term><varname>min_eager_agg_group_size</varname> (<type>floating point</type>)
       <indexterm>
diff --git a/src/backend/optimizer/path/costsize.c b/src/backend/optimizer/path/costsize.c
index 7bbddb8bee4..9d70c805c74 100644
--- a/src/backend/optimizer/path/costsize.c
+++ b/src/backend/optimizer/path/costsize.c
@@ -120,6 +120,13 @@
  */
 #define APPEND_CPU_COST_MULTIPLIER 0.5
 
+/*
+ * Extra cpu_tuple_cost charged per input tuple for each doubling of a hash
+ * table beyond effective_cpu_cache_size, covering the probes that miss CPU
+ * cache.
+ */
+#define HASH_RESIDENCY_PENALTY	4.0
+
 /*
  * Maximum value for row estimates.  We cap row estimates to this to help
  * ensure that costs based on these estimates remain within the range of what
@@ -138,6 +145,7 @@ double		parallel_setup_cost = DEFAULT_PARALLEL_SETUP_COST;
 double		recursive_worktable_factor = DEFAULT_RECURSIVE_WORKTABLE_FACTOR;
 
 int			effective_cache_size = DEFAULT_EFFECTIVE_CACHE_SIZE;
+int			effective_cpu_cache_size = DEFAULT_EFFECTIVE_CPU_CACHE_SIZE;
 
 Cost		disable_cost = 1.0e10;
 
@@ -2806,6 +2814,7 @@ cost_agg(Path *path, PlannerInfo *root,
 	double		output_tuples;
 	Cost		startup_cost;
 	Cost		total_cost;
+	Cost		hash_entry_cost;
 	const AggClauseCosts dummy_aggcosts = {0};
 
 	/* Use all-zero per-aggregate costs if NULL is passed */
@@ -2815,6 +2824,36 @@ cost_agg(Path *path, PlannerInfo *root,
 		aggcosts = &dummy_aggcosts;
 	}
 
+	/* Only the hashed strategies probe a hash table. */
+	hash_entry_cost = 0;
+	if ((aggstrategy == AGG_HASHED || aggstrategy == AGG_MIXED) &&
+		effective_cpu_cache_size > 0)
+	{
+		double		entrysize;
+		double		table_bytes;
+		double		cache_bytes = effective_cpu_cache_size * 1024.0;
+		Size		mem_limit;
+		uint64		ngroups_limit;
+		int			num_partitions;
+
+		entrysize = hash_agg_entry_size(list_length(root->aggtransinfos),
+										input_width,
+										aggcosts->transitionSpace);
+
+		/*
+		 * The disk costs below already charge for spilling past the
+		 * in-memory limit.  Clamping bounds the cost when numGroups is
+		 * overestimated.
+		 */
+		hash_agg_set_limits(entrysize, numGroups, 0, &mem_limit,
+							&ngroups_limit, &num_partitions);
+		table_bytes = Min(entrysize * numGroups, (double) mem_limit);
+
+		if (table_bytes > cache_bytes)
+			hash_entry_cost = cpu_tuple_cost * HASH_RESIDENCY_PENALTY
+				* LOG2(table_bytes / cache_bytes) * input_tuples;
+	}
+
 	/*
 	 * The transCost.per_tuple component of aggcosts should be charged once
 	 * per input tuple, corresponding to the costs of evaluating the aggregate
@@ -2828,6 +2867,10 @@ cost_agg(Path *path, PlannerInfo *root,
 	 * We will produce a single output tuple if not grouping, and a tuple per
 	 * group otherwise.  We charge cpu_tuple_cost for each output tuple.
 	 *
+	 * AGG_HASHED and AGG_MIXED additionally pay hash_entry_cost per input
+	 * tuple for the memory traffic of building a hash table too large to stay
+	 * in cache.  This cost is zero for a table that fits.
+	 *
 	 * Note: in this cost model, AGG_SORTED and AGG_HASHED have exactly the
 	 * same total CPU cost, but AGG_SORTED has lower startup cost.  If the
 	 * input path is already sorted appropriately, AGG_SORTED should be
@@ -2859,6 +2902,7 @@ cost_agg(Path *path, PlannerInfo *root,
 		total_cost += aggcosts->transCost.startup;
 		total_cost += aggcosts->transCost.per_tuple * input_tuples;
 		total_cost += (cpu_operator_cost * numGroupCols) * input_tuples;
+		total_cost += hash_entry_cost;
 		total_cost += aggcosts->finalCost.startup;
 		total_cost += aggcosts->finalCost.per_tuple * numGroups;
 		total_cost += cpu_tuple_cost * numGroups;
@@ -2892,6 +2936,7 @@ cost_agg(Path *path, PlannerInfo *root,
 		startup_cost += aggcosts->transCost.per_tuple * input_tuples;
 		/* cost of computing hash value */
 		startup_cost += (cpu_operator_cost * numGroupCols) * input_tuples;
+		startup_cost += hash_entry_cost;
 		startup_cost += aggcosts->finalCost.startup;
 
 		total_cost = startup_cost;
diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat
index 3c5e16ad1e7..864dc10bba2 100644
--- a/src/backend/utils/misc/guc_parameters.dat
+++ b/src/backend/utils/misc/guc_parameters.dat
@@ -844,6 +844,16 @@
   max => 'INT_MAX',
 },
 
+{ name => 'effective_cpu_cache_size', type => 'int', context => 'PGC_USERSET', group => 'QUERY_TUNING_COST',
+  short_desc => 'Sets the planner\'s assumption about the size of the CPU data cache.',
+  long_desc => 'Hash tables larger than this are charged for the additional memory traffic of probes that miss cache. 0 removes the extra cost.',
+  flags => 'GUC_UNIT_KB | GUC_EXPLAIN',
+  variable => 'effective_cpu_cache_size',
+  boot_val => 'DEFAULT_EFFECTIVE_CPU_CACHE_SIZE',
+  min => '0',
+  max => 'MAX_KILOBYTES',
+},
+
 { name => 'effective_io_concurrency', type => 'int', context => 'PGC_USERSET', group => 'RESOURCES_IO',
   short_desc => 'Number of simultaneous requests that can be handled efficiently by the disk subsystem.',
   long_desc => '0 disables simultaneous requests.',
diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample
index e759f06b50f..720c5b016e5 100644
--- a/src/backend/utils/misc/postgresql.conf.sample
+++ b/src/backend/utils/misc/postgresql.conf.sample
@@ -465,6 +465,7 @@
 #min_parallel_table_scan_size = 8MB
 #min_parallel_index_scan_size = 512kB
 #effective_cache_size = 4GB
+#effective_cpu_cache_size = 8MB
 #min_eager_agg_group_size = 8.0
 
 #jit_above_cost = 100000                # perform JIT compilation if available
diff --git a/src/include/optimizer/cost.h b/src/include/optimizer/cost.h
index 14255c900fc..b047eb732f5 100644
--- a/src/include/optimizer/cost.h
+++ b/src/include/optimizer/cost.h
@@ -32,6 +32,7 @@
 /* defaults for non-Cost parameters */
 #define DEFAULT_RECURSIVE_WORKTABLE_FACTOR  10.0
 #define DEFAULT_EFFECTIVE_CACHE_SIZE  524288	/* measured in pages */
+#define DEFAULT_EFFECTIVE_CPU_CACHE_SIZE  8192	/* measured in kilobytes */
 
 typedef enum
 {
diff --git a/src/include/optimizer/optimizer.h b/src/include/optimizer/optimizer.h
index cb6241e2bdd..7cf2948ad51 100644
--- a/src/include/optimizer/optimizer.h
+++ b/src/include/optimizer/optimizer.h
@@ -79,6 +79,7 @@ extern PGDLLIMPORT double parallel_tuple_cost;
 extern PGDLLIMPORT double parallel_setup_cost;
 extern PGDLLIMPORT double recursive_worktable_factor;
 extern PGDLLIMPORT int effective_cache_size;
+extern PGDLLIMPORT int effective_cpu_cache_size;
 
 extern double clamp_row_est(double nrows);
 extern int32 clamp_width_est(int64 tuple_width);
