From ccd88bc8dbb674476b5ce68a8b114b4e57fbc067 Mon Sep 17 00:00:00 2001 From: William Bernbaum Date: Wed, 26 Aug 2026 12:51:09 -0700 Subject: [PATCH v1 09/16] Deduplicate via semijoin Where a join input only decides which rows of the other survive, add paths that build the deduplicated relation with a semijoin, so the join stops at the first match and emits one row per lefthand row instead of one row per match. --- src/backend/optimizer/path/joinrels.c | 86 ++++++++++++++++++- src/test/regress/expected/eager_aggregate.out | 26 ++---- 2 files changed, 93 insertions(+), 19 deletions(-) diff --git a/src/backend/optimizer/path/joinrels.c b/src/backend/optimizer/path/joinrels.c index d5557d88686..b0df37ca4f3 100644 --- a/src/backend/optimizer/path/joinrels.c +++ b/src/backend/optimizer/path/joinrels.c @@ -41,12 +41,16 @@ static bool restriction_is_constant_false(List *restrictlist, static void make_grouped_join_rel(PlannerInfo *root, RelOptInfo *rel1, RelOptInfo *rel2, RelOptInfo *joinrel, SpecialJoinInfo *sjinfo, List *restrictlist); +static bool dedup_rhs_unobservable(PlannerInfo *root, RelOptInfo *grouped_rel, + RelOptInfo *rhs); +static void make_dedup_semijoin_paths(PlannerInfo *root, RelOptInfo *lhs, + RelOptInfo *rhs, RelOptInfo *joinrel, + RelOptInfo *grouped_rel, + List *restrictlist); static void make_deduplicated_join_rel(PlannerInfo *root, RelOptInfo *rel1, RelOptInfo *rel2, RelOptInfo *joinrel, SpecialJoinInfo *sjinfo, List *restrictlist); -static bool dedup_rhs_unobservable(PlannerInfo *root, RelOptInfo *grouped_rel, - RelOptInfo *rhs); static void populate_joinrel_with_paths(PlannerInfo *root, RelOptInfo *rel1, RelOptInfo *rel2, RelOptInfo *joinrel, SpecialJoinInfo *sjinfo, List *restrictlist); @@ -1124,6 +1128,17 @@ make_deduplicated_join_rel(PlannerInfo *root, RelOptInfo *rel1, if (IS_DUMMY_REL(grouped_rel)) return; + /* A filtering input needs only one match */ + if (sjinfo->jointype == JOIN_INNER) + { + if (dedup_rhs_unobservable(root, grouped_rel, rel2)) + make_dedup_semijoin_paths(root, rel1, rel2, joinrel, grouped_rel, + restrictlist); + if (dedup_rhs_unobservable(root, grouped_rel, rel1)) + make_dedup_semijoin_paths(root, rel2, rel1, joinrel, grouped_rel, + restrictlist); + } + /* generate_grouped_paths() covers the case of two plain inputs */ if (rel1_empty && rel2_empty) return; @@ -1182,6 +1197,73 @@ dedup_rhs_unobservable(PlannerInfo *root, RelOptInfo *grouped_rel, return true; } +/* + * make_dedup_semijoin_paths + * Add paths that reach the deduplicated relation through a semijoin. + * + * The SpecialJoinInfo exists to cost these paths. Join order enumeration + * works from root->join_info_list, which still describes the query's own + * joins. The paths land in the deduplicated relation beside the ones already + * there, so cost chooses between probing for a match and deduplicating below + * the join. + */ +static void +make_dedup_semijoin_paths(PlannerInfo *root, RelOptInfo *lhs, RelOptInfo *rhs, + RelOptInfo *joinrel, RelOptInfo *grouped_rel, + List *restrictlist) +{ + SpecialJoinInfo *sjinfo; + RelOptInfo *semi; + SavedSelectivities *saved; + + /* Reducing matches to their existence is only safe if none are counted */ + Assert(root->agg_clause_list == NIL); + + sjinfo = makeNode(SpecialJoinInfo); + sjinfo->min_lefthand = lhs->relids; + sjinfo->min_righthand = rhs->relids; + sjinfo->syn_lefthand = lhs->relids; + sjinfo->syn_righthand = rhs->relids; + sjinfo->jointype = JOIN_SEMI; + sjinfo->ojrelid = 0; + sjinfo->commute_above_l = NULL; + sjinfo->commute_above_r = NULL; + sjinfo->commute_below_l = NULL; + sjinfo->commute_below_r = NULL; + sjinfo->lhs_strict = false; + + /* + * A semijoin can be planned by deduplicating the righthand side on the + * join keys and joining that as an inner join. That sorts or hashes + * every righthand row before the join starts. The point here is to stop + * at the first match, touching only as much of the righthand side as that + * takes. + */ + sjinfo->semi_can_btree = false; + sjinfo->semi_can_hash = false; + sjinfo->semi_operators = NIL; + sjinfo->semi_rhs_exprs = NIL; + + semi = build_grouped_rel(root, joinrel); + semi->reltarget = grouped_rel->agg_info->agg_input; + + /* These clauses belong to the inner join we are shadowing */ + saved = hide_clause_selectivities(restrictlist); + + set_joinrel_size_estimates(root, semi, lhs, rhs, sjinfo, restrictlist); + + add_paths_to_joinrel(root, semi, lhs, rhs, JOIN_SEMI, sjinfo, + restrictlist); + + restore_clause_selectivities(saved); + + if (semi->pathlist == NIL) + return; + + set_cheapest(semi); + generate_grouped_paths(root, grouped_rel, semi); +} + /* * populate_joinrel_with_paths * Add paths to the given joinrel for given pair of joining relations. The diff --git a/src/test/regress/expected/eager_aggregate.out b/src/test/regress/expected/eager_aggregate.out index aa76e5cd92e..96b28cb9b61 100644 --- a/src/test/regress/expected/eager_aggregate.out +++ b/src/test/regress/expected/eager_aggregate.out @@ -1948,8 +1948,8 @@ SELECT DISTINCT t1.id FROM eager_distinct_t1 t1 JOIN eager_distinct_t2 t2 ON t2.t1_id = t1.id ORDER BY t1.id; - QUERY PLAN ------------------------------------------------------------------------------ + QUERY PLAN +----------------------------------------------------------------- Sort Output: t1.id Sort Key: t1.id @@ -1959,22 +1959,14 @@ ORDER BY t1.id; -> Sort Output: t1.id Sort Key: t1.id - -> Hash Join + -> Nested Loop Semi Join Output: t1.id - Hash Cond: (t2.t1_id = t1.id) - -> Partial GroupAggregate - Output: t2.t1_id - Group Key: t2.t1_id - -> Sort - Output: t2.t1_id - Sort Key: t2.t1_id - -> Seq Scan on public.eager_distinct_t2 t2 - Output: t2.t1_id - -> Hash - Output: t1.id - -> Seq Scan on public.eager_distinct_t1 t1 - Output: t1.id -(24 rows) + Join Filter: (t1.id = t2.t1_id) + -> Seq Scan on public.eager_distinct_t1 t1 + Output: t1.id, t1.val + -> Seq Scan on public.eager_distinct_t2 t2 + Output: t2.id, t2.t1_id, t2.flag +(16 rows) SELECT DISTINCT t1.id FROM eager_distinct_t1 t1