Hi hackers,
The planner currently calls approx_tuple_count() to estimate
hashjointuples and mergejointuples. That makes sense when
joinrestrictinfo contains additional clauses beyond the hash/merge
equality list. But if all join restriction clauses are exactly those
hash/merge clauses, the estimate already computed in
path->jpath.path.rows is usually more accurate (and free).
This patch reuses path->jpath.path.rows in that case and skips
approx_tuple_count().
Regression results
==================
| actual | approx | estimate
---------------------+--------+--------+-------
join.sql q1 | 5 | 1 | 5
join.sql q2 | 10 000 | 1 | 10 000
join.sql q3 | 5 | 5 | 5
join.sql q4 | 5 | 5 | 5
join.sql q5 | 5 | 1 | 5
partition_join q1 | 200 | 1 | 200
partition_join q2 | 42 | 84 | 84
partition_join q3 | 8 | 1 | 8
postgres_fdw | 2001 | 1001 | 2001
select_parallel q1 | 0 | 5 000 | 5 000
updatable_views q1 | 2 | 2 | 423
Two cases get worse: select_parallel.sql and updatable_views.sql.
Looking forward to your feedback!
--
Best regards,
Ilia Evdokimov,
Tantor Labs LLC.
From 79392e58873f60fc9b62fc7d8816ad973e775026 Mon Sep 17 00:00:00 2001
From: Ilia Evdokimov <ilya.evdoki...@tantorlabs.ru>
Date: Thu, 10 Jul 2025 13:04:04 +0300
Subject: [PATCH v1] Improve row count estimates for hash/merge joins that have
no extra filters
If joinrestrictinfo holds nothing but the hash/merge equality keys,
path->jpath.path.rows already gives the right inner-join row count.
Skip the extra call to approx_tuple_count() and use that value instead.
---
.../postgres_fdw/expected/postgres_fdw.out | 26 ++++++-----
src/backend/optimizer/path/costsize.c | 17 +++++--
src/test/isolation/expected/merge-update.out | 4 +-
src/test/regress/expected/join.out | 46 +++++++++----------
src/test/regress/expected/partition_join.out | 37 +++++++--------
src/test/regress/expected/select_parallel.out | 34 +++++++-------
src/test/regress/expected/updatable_views.out | 16 +++----
7 files changed, 94 insertions(+), 86 deletions(-)
diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index 2185b42bb4f..3f06fde5547 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -8698,26 +8698,28 @@ select foo.f1, loct1.f1 from foo join loct1 on (foo.f1 = loct1.f1) order by foo.
-- list but no output change as compared to the previous query
explain (verbose, costs off)
select foo.f1, loct1.f1 from foo left join loct1 on (foo.f1 = loct1.f1) order by foo.f2 offset 10 limit 10;
- QUERY PLAN
---------------------------------------------------------------------------------------------------
+ QUERY PLAN
+--------------------------------------------------------------------------------------------------------
Limit
Output: foo.f1, loct1.f1, foo.f2
-> Sort
Output: foo.f1, loct1.f1, foo.f2
Sort Key: foo.f2
- -> Merge Left Join
+ -> Merge Right Join
Output: foo.f1, loct1.f1, foo.f2
- Merge Cond: (foo.f1 = loct1.f1)
- -> Merge Append
- Sort Key: foo.f1
- -> Index Scan using i_foo_f1 on public.foo foo_1
- Output: foo_1.f1, foo_1.f2
- -> Foreign Scan on public.foo2 foo_2
- Output: foo_2.f1, foo_2.f2
- Remote SQL: SELECT f1, f2 FROM public.loct1 ORDER BY f1 ASC NULLS LAST
+ Merge Cond: (loct1.f1 = foo.f1)
-> Index Only Scan using i_loct1_f1 on public.loct1
Output: loct1.f1
-(17 rows)
+ -> Materialize
+ Output: foo.f1, foo.f2
+ -> Merge Append
+ Sort Key: foo.f1
+ -> Index Scan using i_foo_f1 on public.foo foo_1
+ Output: foo_1.f1, foo_1.f2
+ -> Foreign Scan on public.foo2 foo_2
+ Output: foo_2.f1, foo_2.f2
+ Remote SQL: SELECT f1, f2 FROM public.loct1 ORDER BY f1 ASC NULLS LAST
+(19 rows)
select foo.f1, loct1.f1 from foo left join loct1 on (foo.f1 = loct1.f1) order by foo.f2 offset 10 limit 10;
f1 | f1
diff --git a/src/backend/optimizer/path/costsize.c b/src/backend/optimizer/path/costsize.c
index 1f04a2c182c..f313279ba9e 100644
--- a/src/backend/optimizer/path/costsize.c
+++ b/src/backend/optimizer/path/costsize.c
@@ -3934,8 +3934,14 @@ final_cost_mergejoin(PlannerInfo *root, MergePath *path,
/*
* Get approx # tuples passing the mergequals. We use approx_tuple_count
* here because we need an estimate done with JOIN_INNER semantics.
+ * However, if joinrestrictinfo consists only of those base hash/merge
+ * clauses, we have already computed an equally (or more) accurate figure
+ * in path->jpath.path.rows
*/
- mergejointuples = approx_tuple_count(root, &path->jpath, mergeclauses);
+ if (list_length(path->jpath.joinrestrictinfo) > list_length(mergeclauses))
+ mergejointuples = approx_tuple_count(root, &path->jpath, mergeclauses);
+ else
+ mergejointuples = path->jpath.path.rows;
/*
* When there are equal merge keys in the outer relation, the mergejoin
@@ -4525,9 +4531,14 @@ final_cost_hashjoin(PlannerInfo *root, HashPath *path,
/*
* Get approx # tuples passing the hashquals. We use
* approx_tuple_count here because we need an estimate done with
- * JOIN_INNER semantics.
+ * JOIN_INNER semantics. However, if joinrestrictinfo consists
+ * only of those base hash/merge clauses, we have already computed
+ * an equally (or more) accurate figure in path->jpath.path.rows
*/
- hashjointuples = approx_tuple_count(root, &path->jpath, hashclauses);
+ if (list_length(path->jpath.joinrestrictinfo) > list_length(hashclauses))
+ hashjointuples = approx_tuple_count(root, &path->jpath, hashclauses);
+ else
+ hashjointuples = path->jpath.path.rows;
}
/*
diff --git a/src/test/isolation/expected/merge-update.out b/src/test/isolation/expected/merge-update.out
index 677263d1ec1..3f8a591e815 100644
--- a/src/test/isolation/expected/merge-update.out
+++ b/src/test/isolation/expected/merge-update.out
@@ -44,15 +44,15 @@ step merge2a:
merge_action|old |new |key|val
------------+------------------------------+------------------------------------------------------------+---+------------------------------------------------------
-UPDATE |(2,"setup1 updated by merge1")|(3,"setup1 updated by merge1 source not matched by merge2a")| 3|setup1 updated by merge1 source not matched by merge2a
INSERT | |(1,merge2a) | 1|merge2a
+UPDATE |(2,"setup1 updated by merge1")|(3,"setup1 updated by merge1 source not matched by merge2a")| 3|setup1 updated by merge1 source not matched by merge2a
(2 rows)
step select2: SELECT * FROM target;
key|val
---+------------------------------------------------------
- 3|setup1 updated by merge1 source not matched by merge2a
1|merge2a
+ 3|setup1 updated by merge1 source not matched by merge2a
(2 rows)
step c2: COMMIT;
diff --git a/src/test/regress/expected/join.out b/src/test/regress/expected/join.out
index 46ddfa844c5..96a9486ccf5 100644
--- a/src/test/regress/expected/join.out
+++ b/src/test/regress/expected/join.out
@@ -2288,10 +2288,10 @@ order by 1, 2;
-------------------------------------------
Sort
Sort Key: i1.q1, i1.q2
- -> Hash Left Join
- Hash Cond: (i1.q2 = i2.q2)
+ -> Nested Loop Left Join
+ Join Filter: (i1.q2 = i2.q2)
-> Seq Scan on int8_tbl i1
- -> Hash
+ -> Materialize
-> Seq Scan on int8_tbl i2
Filter: (q1 = 123)
(8 rows)
@@ -4457,11 +4457,11 @@ select unique1, x from tenk1 full join f_immutable_int4(1) x on unique1 = x;
QUERY PLAN
----------------------------------------------------
Merge Full Join
- Merge Cond: (tenk1.unique1 = (1))
- -> Index Only Scan using tenk1_unique1 on tenk1
+ Merge Cond: ((1) = tenk1.unique1)
-> Sort
Sort Key: (1)
-> Result
+ -> Index Only Scan using tenk1_unique1 on tenk1
(6 rows)
-- check that pullup of a const function allows further const-folding
@@ -6036,15 +6036,14 @@ select * from int8_tbl t1 left join
(int8_tbl t2 inner join int8_tbl t3 on false
left join int8_tbl t4 on t2.q2 = t4.q2)
on t1.q1 = t2.q1;
- QUERY PLAN
---------------------------------------
- Hash Left Join
- Hash Cond: (t1.q1 = q1)
+ QUERY PLAN
+--------------------------------
+ Nested Loop Left Join
+ Join Filter: (t1.q1 = q1)
-> Seq Scan on int8_tbl t1
- -> Hash
- -> Result
- One-Time Filter: false
-(6 rows)
+ -> Result
+ One-Time Filter: false
+(5 rows)
-- deduce constant-false from an EquivalenceClass
explain (costs off)
@@ -6052,15 +6051,14 @@ select * from int8_tbl t1 left join
(int8_tbl t2 inner join int8_tbl t3 on (t2.q1-t3.q2) = 0 and (t2.q1-t3.q2) = 1
left join int8_tbl t4 on t2.q2 = t4.q2)
on t1.q1 = t2.q1;
- QUERY PLAN
---------------------------------------
- Hash Left Join
- Hash Cond: (t1.q1 = q1)
+ QUERY PLAN
+--------------------------------
+ Nested Loop Left Join
+ Join Filter: (t1.q1 = q1)
-> Seq Scan on int8_tbl t1
- -> Hash
- -> Result
- One-Time Filter: false
-(6 rows)
+ -> Result
+ One-Time Filter: false
+(5 rows)
-- pseudoconstant based on an outer-level Param
explain (costs off)
@@ -8445,12 +8443,12 @@ select * from int4_tbl a,
Output: a.f1, b.f1, c.q1, c.q2
-> Seq Scan on public.int4_tbl a
Output: a.f1
- -> Hash Left Join
+ -> Nested Loop Left Join
Output: b.f1, c.q1, c.q2
- Hash Cond: (b.f1 = c.q1)
+ Join Filter: (b.f1 = c.q1)
-> Seq Scan on public.int4_tbl b
Output: b.f1
- -> Hash
+ -> Materialize
Output: c.q1, c.q2
-> Seq Scan on public.int8_tbl c
Output: c.q1, c.q2
diff --git a/src/test/regress/expected/partition_join.out b/src/test/regress/expected/partition_join.out
index d5368186caa..c5d7c4eb23e 100644
--- a/src/test/regress/expected/partition_join.out
+++ b/src/test/regress/expected/partition_join.out
@@ -1623,8 +1623,8 @@ EXPLAIN (COSTS OFF)
SELECT t1.a, t1.c, t2.b, t2.c FROM (SELECT * FROM prt1 WHERE a = 1 AND a = 2) t1 RIGHT JOIN prt2 t2 ON t1.a = t2.b, prt1 t3 WHERE t2.b = t3.a;
QUERY PLAN
--------------------------------------------------
- Hash Left Join
- Hash Cond: (t2.b = a)
+ Nested Loop Left Join
+ Join Filter: (a = t2.b)
-> Append
-> Hash Join
Hash Cond: (t3_1.a = t2_1.b)
@@ -1641,10 +1641,9 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM (SELECT * FROM prt1 WHERE a = 1 AND a = 2) t1
-> Seq Scan on prt1_p3 t3_3
-> Hash
-> Seq Scan on prt2_p3 t2_3
- -> Hash
- -> Result
- One-Time Filter: false
-(21 rows)
+ -> Result
+ One-Time Filter: false
+(20 rows)
EXPLAIN (COSTS OFF)
SELECT t1.a, t1.c, t2.b, t2.c FROM (SELECT * FROM prt1 WHERE a = 1 AND a = 2) t1 FULL JOIN prt2 t2 ON t1.a = t2.b WHERE t2.a = 0 ORDER BY t1.a, t2.b;
@@ -1652,8 +1651,8 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM (SELECT * FROM prt1 WHERE a = 1 AND a = 2) t1
--------------------------------------------
Sort
Sort Key: a, t2.b
- -> Hash Left Join
- Hash Cond: (t2.b = a)
+ -> Nested Loop Left Join
+ Join Filter: (a = t2.b)
-> Append
-> Seq Scan on prt2_p1 t2_1
Filter: (a = 0)
@@ -1661,10 +1660,9 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM (SELECT * FROM prt1 WHERE a = 1 AND a = 2) t1
Filter: (a = 0)
-> Seq Scan on prt2_p3 t2_3
Filter: (a = 0)
- -> Hash
- -> Result
- One-Time Filter: false
-(14 rows)
+ -> Result
+ One-Time Filter: false
+(13 rows)
--
-- tests for hash partitioned tables.
@@ -2238,20 +2236,19 @@ SELECT COUNT(*) FROM prt1_l t1 LEFT JOIN LATERAL
-- join with one side empty
EXPLAIN (COSTS OFF)
SELECT t1.a, t1.c, t2.b, t2.c FROM (SELECT * FROM prt1_l WHERE a = 1 AND a = 2) t1 RIGHT JOIN prt2_l t2 ON t1.a = t2.b AND t1.b = t2.a AND t1.c = t2.c;
- QUERY PLAN
--------------------------------------------------------------------------
- Hash Left Join
- Hash Cond: ((t2.b = a) AND (t2.a = b) AND ((t2.c)::text = (c)::text))
+ QUERY PLAN
+---------------------------------------------------------------------------
+ Nested Loop Left Join
+ Join Filter: ((a = t2.b) AND (b = t2.a) AND ((c)::text = (t2.c)::text))
-> Append
-> Seq Scan on prt2_l_p1 t2_1
-> Seq Scan on prt2_l_p2_p1 t2_2
-> Seq Scan on prt2_l_p2_p2 t2_3
-> Seq Scan on prt2_l_p3_p1 t2_4
-> Seq Scan on prt2_l_p3_p2 t2_5
- -> Hash
- -> Result
- One-Time Filter: false
-(11 rows)
+ -> Result
+ One-Time Filter: false
+(10 rows)
-- Test case to verify proper handling of subqueries in a partitioned delete.
-- The weird-looking lateral join is just there to force creation of a
diff --git a/src/test/regress/expected/select_parallel.out b/src/test/regress/expected/select_parallel.out
index 0185ef661b1..418174991eb 100644
--- a/src/test/regress/expected/select_parallel.out
+++ b/src/test/regress/expected/select_parallel.out
@@ -1125,27 +1125,27 @@ reset role;
explain (costs off, verbose)
select count(*) from tenk1 a where (unique1, two) in
(select unique1, row_number() over() from tenk1 b);
- QUERY PLAN
-----------------------------------------------------------------------------------------
+ QUERY PLAN
+----------------------------------------------------------------------------------------------
Aggregate
Output: count(*)
- -> Hash Right Semi Join
- Hash Cond: ((b.unique1 = a.unique1) AND ((row_number() OVER w1) = a.two))
- -> WindowAgg
- Output: b.unique1, row_number() OVER w1
- Window: w1 AS (ROWS UNBOUNDED PRECEDING)
- -> Gather
- Output: b.unique1
- Workers Planned: 4
- -> Parallel Index Only Scan using tenk1_unique1 on public.tenk1 b
- Output: b.unique1
- -> Hash
+ -> Hash Semi Join
+ Hash Cond: ((a.unique1 = b.unique1) AND (a.two = (row_number() OVER w1)))
+ -> Gather
Output: a.unique1, a.two
- -> Gather
+ Workers Planned: 4
+ -> Parallel Seq Scan on public.tenk1 a
Output: a.unique1, a.two
- Workers Planned: 4
- -> Parallel Seq Scan on public.tenk1 a
- Output: a.unique1, a.two
+ -> Hash
+ Output: b.unique1, (row_number() OVER w1)
+ -> WindowAgg
+ Output: b.unique1, row_number() OVER w1
+ Window: w1 AS (ROWS UNBOUNDED PRECEDING)
+ -> Gather
+ Output: b.unique1
+ Workers Planned: 4
+ -> Parallel Index Only Scan using tenk1_unique1 on public.tenk1 b
+ Output: b.unique1
(19 rows)
-- LIMIT/OFFSET within sub-selects can't be pushed to workers.
diff --git a/src/test/regress/expected/updatable_views.out b/src/test/regress/expected/updatable_views.out
index 095df0a670c..aea5b52a70c 100644
--- a/src/test/regress/expected/updatable_views.out
+++ b/src/test/regress/expected/updatable_views.out
@@ -520,9 +520,9 @@ MERGE INTO rw_view1 t
merge_action | a | b | old | new | a | b | c | d | a | b | c | d | a | b | c | d
--------------+---+----+----------------------------------------+----------------------------------------+---+-------------+-------+----------------+---+-------------+-------+----------------+---+-------------+-------+----------------
UPDATE | 1 | R1 | (1,"ROW 1",Const,"b: ROW 1") | (1,R1,Const,"b: R1") | 1 | ROW 1 | Const | b: ROW 1 | 1 | R1 | Const | b: R1 | 1 | R1 | Const | b: R1
- DELETE | | | (5,Unspecified,Const,"b: Unspecified") | | 5 | Unspecified | Const | b: Unspecified | | | | | 5 | Unspecified | Const | b: Unspecified
DELETE | 2 | R2 | (2,Unspecified,Const,"b: Unspecified") | | 2 | Unspecified | Const | b: Unspecified | | | | | 2 | Unspecified | Const | b: Unspecified
INSERT | 3 | R3 | | (3,Unspecified,Const,"b: Unspecified") | | | | | 3 | Unspecified | Const | b: Unspecified | 3 | Unspecified | Const | b: Unspecified
+ DELETE | | | (5,Unspecified,Const,"b: Unspecified") | | 5 | Unspecified | Const | b: Unspecified | | | | | 5 | Unspecified | Const | b: Unspecified
(4 rows)
SELECT * FROM base_tbl ORDER BY a;
@@ -585,14 +585,14 @@ MERGE INTO rw_view1 t
QUERY PLAN
-------------------------------------------------------------------
Merge on base_tbl
- -> Hash Left Join
- Hash Cond: (base_tbl.a = generate_series.generate_series)
- -> Bitmap Heap Scan on base_tbl
- Recheck Cond: (a > 0)
- -> Bitmap Index Scan on base_tbl_pkey
- Index Cond: (a > 0)
+ -> Hash Right Join
+ Hash Cond: (generate_series.generate_series = base_tbl.a)
+ -> Function Scan on generate_series
-> Hash
- -> Function Scan on generate_series
+ -> Bitmap Heap Scan on base_tbl
+ Recheck Cond: (a > 0)
+ -> Bitmap Index Scan on base_tbl_pkey
+ Index Cond: (a > 0)
(9 rows)
EXPLAIN (costs off)
--
2.34.1