From c1189e46f6dc6dea2fc8695d7824ae9ee1c875a7 Mon Sep 17 00:00:00 2001 From: Haibo Yan Date: Tue, 18 Aug 2026 23:48:41 -0700 Subject: [PATCH] Fix candidate-count estimate for right-semi/right-anti hash joins JOIN_RIGHT_SEMI and JOIN_RIGHT_ANTI hash join paths physically swap the two semi/anti join inputs relative to the canonical orientation: the relation that is logically the semijoin/antijoin's outer (LHS) is hashed and probed by what is logically the inner (RHS). This lets the planner hash whichever side is smaller instead of always hashing the logical inner. compute_semi_anti_join_factors() and the semifactors it produces (outer_match_frac, match_count) describe the canonical, un-swapped semi/anti orientation: they are computed once per joinrel from the sjinfo-based canonical outer and inner relations, independently of which of the two sibling hash join paths (ordinary or "right") is being costed. final_cost_hashjoin()'s SEMI/ANTI/inner_unique costing branch combines that fraction with outer_path_rows to estimate hashjointuples, the number of hash-clause candidate pairs the executor will examine. For an ordinary-orientation path this is correct, because outer_path_rows there is the same canonical outer relation semifactors was computed for. But for a physically swapped right-semi/right-anti path, outer_path_rows belongs to the opposite relation: the estimate ends up multiplying one relation's row count by a match fraction that was computed for a different relation's join behavior. The resulting hashjointuples estimate can be wrong by orders of magnitude. In one reproducer, a right semi join between a 100-row unique-keyed table and a 2,000,000-row table produces an estimate of 2,000,000 hash-clause candidates versus roughly 400 actually examined at execution time. Because that inflated estimate feeds directly into cpu_tuple_cost's contribution to the join's cost, it makes an otherwise attractive right-semi/right-anti plan (hash the small side, scan the large one) look far more expensive than the alternative of hashing the large side instead, causing the planner to avoid it. approx_tuple_count() already estimates this same population -- the number of hash-clause candidate pairs -- for the non-SEMI/ANTI branch of the same function, via a formula (selectivity times the product of the two input row counts) that is symmetric under exchanging which of the two relations is called "outer": it does not depend on which side of the join is physically probed. Use it for JOIN_RIGHT_SEMI and JOIN_RIGHT_ANTI too, leaving the existing CPU cost formula (cpu_tuple_cost plus qp_qual_cost.per_tuple, both charged on hashjointuples) unchanged. This fixes the candidate-count estimate at its source, rather than changing what population the existing cost terms are applied to. Add regression tests covering both JOIN_RIGHT_SEMI and JOIN_RIGHT_ANTI with a unique-keyed build side, including a sparse case with a much larger outer/probe relation where only a small fraction of its rows can hash-match -- the regime where the old estimate's error was largest -- covering both jointypes on the same pair of tables. --- src/backend/optimizer/path/costsize.c | 15 ++++- src/test/regress/expected/join.out | 96 +++++++++++++++++++++++++++ src/test/regress/sql/join.sql | 62 +++++++++++++++++ 3 files changed, 171 insertions(+), 2 deletions(-) diff --git a/src/backend/optimizer/path/costsize.c b/src/backend/optimizer/path/costsize.c index fd794c946ab..fe2d00cd76f 100644 --- a/src/backend/optimizer/path/costsize.c +++ b/src/backend/optimizer/path/costsize.c @@ -4650,8 +4650,19 @@ final_cost_hashjoin(PlannerInfo *root, HashPath *path, (outer_path_rows - outer_matched_rows) * clamp_row_est(inner_path_rows / virtualbuckets) * 0.05; - /* Get # of tuples that will pass the basic join */ - if (path->jpath.jointype == JOIN_ANTI) + /* + * Get # of tuples that will pass the basic join. For + * JOIN_RIGHT_SEMI/JOIN_RIGHT_ANTI, the physical outer and inner + * paths are swapped, while the semi/anti factors above describe + * the canonical orientation. Applying outer_match_frac to + * outer_path_rows would therefore mix estimates for different + * relations. Use the symmetric join-pair estimate from + * approx_tuple_count() instead. + */ + if (path->jpath.jointype == JOIN_RIGHT_SEMI || + path->jpath.jointype == JOIN_RIGHT_ANTI) + hashjointuples = approx_tuple_count(root, &path->jpath, hashclauses); + else if (path->jpath.jointype == JOIN_ANTI) hashjointuples = outer_path_rows - outer_matched_rows; else hashjointuples = outer_matched_rows; diff --git a/src/test/regress/expected/join.out b/src/test/regress/expected/join.out index 05f359d3aa7..19994f269b6 100644 --- a/src/test/regress/expected/join.out +++ b/src/test/regress/expected/join.out @@ -3106,6 +3106,102 @@ and t1.fivethous < 5; -> Parallel Seq Scan on tenk1 t2 (8 rows) +rollback; +-- +-- test candidate-count costing for hash right semi/anti joins when the +-- physical outer path is swapped relative to the canonical semi/anti join +-- +begin; +create temp table hj_small(id int primary key); +create temp table hj_large(v int); +insert into hj_small select i from generate_series(1,200)i; +insert into hj_large select (i % 500) + 11 from generate_series(1,1000)i; +analyze hj_small, hj_large; +-- ensure we hash the small side and scan the large one, not the reverse +explain (costs off) +select count(*) from hj_small s where exists + (select 1 from hj_large r where r.v = s.id); + QUERY PLAN +------------------------------------------ + Aggregate + -> Hash Right Semi Join + Hash Cond: (r.v = s.id) + -> Seq Scan on hj_large r + -> Hash + -> Seq Scan on hj_small s +(6 rows) + +-- and check we get the expected results +select count(*) from hj_small s where exists + (select 1 from hj_large r where r.v = s.id); + count +------- + 190 +(1 row) + +-- likewise for a right anti join +explain (costs off) +select count(*) from hj_small s where not exists + (select 1 from hj_large r where r.v = s.id); + QUERY PLAN +------------------------------------------ + Aggregate + -> Hash Right Anti Join + Hash Cond: (r.v = s.id) + -> Seq Scan on hj_large r + -> Hash + -> Seq Scan on hj_small s +(6 rows) + +select count(*) from hj_small s where not exists + (select 1 from hj_large r where r.v = s.id); + count +------- + 10 +(1 row) + +rollback; +-- +-- same, but with a large outer/probe side and a small unique-keyed build +-- side where only a small fraction of the outer rows can possibly +-- hash-match: this exercises the inner_unique costing branch with the +-- widest orientation mismatch between outer_path_rows and the canonical +-- outer_match_frac, i.e. where the old candidate-count estimate was +-- wrong by the largest margin +-- +begin; +create temp table hj_sparse_small(id int primary key, tag int); +create temp table hj_sparse_large(v int); +insert into hj_sparse_small select g, g from generate_series(1, 50) g; +insert into hj_sparse_large select (g % 50000) + 1 from generate_series(1, 100000) g; +analyze hj_sparse_small, hj_sparse_large; +set max_parallel_workers_per_gather = 0; +set work_mem = '4MB'; +explain (costs off) +select hj_sparse_small.tag from hj_sparse_small where exists + (select 1 from hj_sparse_large where hj_sparse_large.v = hj_sparse_small.id); + QUERY PLAN +------------------------------------------------------- + Hash Right Semi Join + Hash Cond: (hj_sparse_large.v = hj_sparse_small.id) + -> Seq Scan on hj_sparse_large + -> Hash + -> Seq Scan on hj_sparse_small +(5 rows) + +-- likewise for a right anti join, reusing the same tables +explain (costs off) +select hj_sparse_small.tag from hj_sparse_small where not exists + (select 1 from hj_sparse_large where hj_sparse_large.v = hj_sparse_small.id); + QUERY PLAN +------------------------------------------------------- + Hash Right Anti Join + Hash Cond: (hj_sparse_large.v = hj_sparse_small.id) + -> Seq Scan on hj_sparse_large + -> Hash + -> Seq Scan on hj_sparse_small +(5 rows) + rollback; -- -- regression test for bug #13908 (hash join with skew tuples & nbatch increase) diff --git a/src/test/regress/sql/join.sql b/src/test/regress/sql/join.sql index 450bd5bbf2c..c6d990ec0ac 100644 --- a/src/test/regress/sql/join.sql +++ b/src/test/regress/sql/join.sql @@ -779,6 +779,68 @@ and t1.fivethous < 5; rollback; +-- +-- test candidate-count costing for hash right semi/anti joins when the +-- physical outer path is swapped relative to the canonical semi/anti join +-- + +begin; + +create temp table hj_small(id int primary key); +create temp table hj_large(v int); +insert into hj_small select i from generate_series(1,200)i; +insert into hj_large select (i % 500) + 11 from generate_series(1,1000)i; +analyze hj_small, hj_large; + +-- ensure we hash the small side and scan the large one, not the reverse +explain (costs off) +select count(*) from hj_small s where exists + (select 1 from hj_large r where r.v = s.id); + +-- and check we get the expected results +select count(*) from hj_small s where exists + (select 1 from hj_large r where r.v = s.id); + +-- likewise for a right anti join +explain (costs off) +select count(*) from hj_small s where not exists + (select 1 from hj_large r where r.v = s.id); + +select count(*) from hj_small s where not exists + (select 1 from hj_large r where r.v = s.id); + +rollback; + +-- +-- same, but with a large outer/probe side and a small unique-keyed build +-- side where only a small fraction of the outer rows can possibly +-- hash-match: this exercises the inner_unique costing branch with the +-- widest orientation mismatch between outer_path_rows and the canonical +-- outer_match_frac, i.e. where the old candidate-count estimate was +-- wrong by the largest margin +-- + +begin; + +create temp table hj_sparse_small(id int primary key, tag int); +create temp table hj_sparse_large(v int); +insert into hj_sparse_small select g, g from generate_series(1, 50) g; +insert into hj_sparse_large select (g % 50000) + 1 from generate_series(1, 100000) g; +analyze hj_sparse_small, hj_sparse_large; +set max_parallel_workers_per_gather = 0; +set work_mem = '4MB'; + +explain (costs off) +select hj_sparse_small.tag from hj_sparse_small where exists + (select 1 from hj_sparse_large where hj_sparse_large.v = hj_sparse_small.id); + +-- likewise for a right anti join, reusing the same tables +explain (costs off) +select hj_sparse_small.tag from hj_sparse_small where not exists + (select 1 from hj_sparse_large where hj_sparse_large.v = hj_sparse_small.id); + +rollback; + -- -- regression test for bug #13908 (hash join with skew tuples & nbatch increase) -- -- 2.54.0