diff --git a/src/backend/optimizer/path/costsize.c b/src/backend/optimizer/path/costsize.c index 1c575e56ff6..4adb334470c 100644 --- a/src/backend/optimizer/path/costsize.c +++ b/src/backend/optimizer/path/costsize.c @@ -164,6 +164,7 @@ bool enable_parallel_hash = true; bool enable_partition_pruning = true; bool enable_presorted_aggregate = true; bool enable_async_append = true; +bool enable_starjoin_ordering = true; typedef struct { diff --git a/src/backend/optimizer/path/joinrels.c b/src/backend/optimizer/path/joinrels.c index 443e2dca7c0..24c00c03ebb 100644 --- a/src/backend/optimizer/path/joinrels.c +++ b/src/backend/optimizer/path/joinrels.c @@ -32,6 +32,9 @@ static void make_rels_by_clause_joins(PlannerInfo *root, static void make_rels_by_clauseless_joins(PlannerInfo *root, RelOptInfo *old_rel, List *other_rels); +static bool starjoin_order_invalid(PlannerInfo *root, + RelOptInfo *rel1, + RelOptInfo *rel2); static bool has_join_restriction(PlannerInfo *root, RelOptInfo *rel); static bool has_legal_joinclause(PlannerInfo *root, RelOptInfo *rel); static bool restriction_is_constant_false(List *restrictlist, @@ -709,6 +712,14 @@ make_join_rel(PlannerInfo *root, RelOptInfo *rel1, RelOptInfo *rel2) /* We should never try to join two overlapping sets of rels. */ Assert(!bms_overlap(rel1->relids, rel2->relids)); + /* + * If this join would be a redundant ordering of a previously detected + * starjoin cluster, skip it. All cluster orderings have identical cost; + * we keep only the canonical one chosen when building starjoin clusters. + */ + if (starjoin_order_invalid(root, rel1, rel2)) + return NULL; + /* Construct Relids set that identifies the joinrel (without OJ as yet). */ joinrelids = bms_union(rel1->relids, rel2->relids); @@ -2112,3 +2123,107 @@ get_matching_part_pairs(PlannerInfo *root, RelOptInfo *joinrel, *parts2 = lappend(*parts2, child_rel2); } } + +/* + * starjoin_order_invalid + * Return true if the proposed join (rel1, rel2) would build a + * non-canonical ordering of a starjoin cluster. + * + * For every star cluster in root->starjoin_clusters, we enforce the canonical + * decomposition of any joinrel containinig the fact and at least one cluster + * dimension - the dimensions must form a prefix of the order determined when + * collecting the starjoin clusters. + * + * This rule preserves exactly one decomposition per joinrel relid set, so + * each canonical joinrel is still built; redundant orderings are pruned. + * + * We might forbid additional joins, for example we might forbid joinrels + * containing a cluster dimension without the fact. + */ +static bool +starjoin_order_invalid(PlannerInfo *root, RelOptInfo *rel1, RelOptInfo *rel2) +{ + ListCell *lc; + Relids join_relids; + bool violates = false; + + /* No starjoin optimization outside the stardard DP join search. */ + if (root->join_rel_level == NULL) + return false; + + /* no starjoins found (maybe because the option is disabled) */ + if (root->starjoin_clusters == NIL) + return false; + + /* relids of the result */ + join_relids = bms_union(rel1->relids, rel2->relids); + + /* does this contradict any of the clusters we know about */ + foreach(lc, root->starjoin_clusters) + { + StarJoinClusterInfo *c = (StarJoinClusterInfo *) lfirst(lc); + Relids dims; + int ndims; + int idx; + + /* + * If all dimensions are on one side of the join, we've already + * checked the canonical order earlier, when building it. No need + * to redo the check again. + */ + dims = bms_intersect(rel1->relids, c->dim_relids); + if (bms_is_empty(dims)) + { + bms_free(dims); + continue; + } + bms_free(dims); + + dims = bms_intersect(rel2->relids, c->dim_relids); + if (bms_is_empty(dims)) + { + bms_free(dims); + continue; + } + bms_free(dims); + + /* the canonical ordering has to include the fact */ + if (!bms_is_member(c->fact_relid, join_relids)) + { + violates = true; + break; + } + + /* + * Count the prefix length covered by this join. We simply use the + * bitmap of cluster dimensions. For now that's fine, but it we + * choose to allow snowflake joins, we'll need to rethink it and use + * a list or something where the order is not determined by relid + * (probably?) + */ + ndims = 0; + idx = -1; + while ((idx = bms_next_member(c->dim_relids, idx)) >= 0) + { + /* found end of dimension prefix */ + if (!bms_is_member(idx, join_relids)) + break; + + ndims++; + } + + dims = bms_intersect(join_relids, c->dim_relids); + if (bms_num_members(dims) != ndims) + { + bms_free(dims); + violates = true; + break; + } + + bms_free(dims); + } + + bms_free(join_relids); + + return violates; +} diff --git a/src/backend/optimizer/plan/initsplan.c b/src/backend/optimizer/plan/initsplan.c index b38422c47a4..4dbc0512d4e 100644 --- a/src/backend/optimizer/plan/initsplan.c +++ b/src/backend/optimizer/plan/initsplan.c @@ -4295,3 +4295,260 @@ check_memoizable(RestrictInfo *restrictinfo) if (OidIsValid(typentry->hash_proc) && OidIsValid(typentry->eq_opr)) restrictinfo->right_hasheqoperator = typentry->eq_opr; } + + + +/* + * starjoins_canonicalize + * Detect star-shaped sub-joins and determine canonical join order. + * + * A starjoin cluster is a fact relation joined to two or more dimensions + * via an equality condition matching a foreign key, satisfying these + * conditions: + * + * 1. The dimension is a plain base relation (RELOPT_BASEREL). + * + * 2. All join columns on the fact are NOT NULL. + * + * 3. The dimension has no local restriction quals (baserestrictinfo). + * + * 4. The dimension has no relevant join clauses to any rel other than + * the fact (checked via have_relevant_joinclause, which also covers + * equivalence-class joins). + * + * 5. The dimension does not participate in any SpecialJoinInfo. + * + * 6. The dimension has no lateral references in or out. + * + * 7. The dimension is not constrained by any PlaceHolderInfo. + * + * Under these conditions, the join is strictly cardinality-preserving with + * respect to the fact table. And all join orderings for a cluster produce + * identical row counts and identical per-step costs. The join_search can + * therefore consider only a single canonical ordering without losing the + * optimal plan. + * + * Dimensions that fail the preconditions are simply not added to any + * cluster; the planner then handles them in the ordinary way. A fact + * with fewer than two qualifying dimensions produces no cluster (there + * is nothing to deduplicate). + * + * Must be called after match_foreign_keys_to_quals(), and after lateral, + * PHV and SpecialJoinInfo information have been finalized. + */ +void +generate_starjoin_clusters(PlannerInfo *root) +{ + int nrels; + List **fact_dims; + int f; + ListCell *lc; + + /* do nothing if optimization not enabled */ + if (!enable_starjoin_ordering) + return; + + /* also, do nothing if there are no foreign key joins */ + if (root->fkey_list == NIL) + return; + + nrels = root->simple_rel_array_size; + fact_dims = (List **) palloc0(nrels * sizeof(List *)); + + /* + * Group fully-matched FKs by referencing (fact) relid, collecting + * candidate dimension relids. match_foreign_keys_to_quals() has + * already filtered fkey_list to fully-matched FKs. + */ + foreach(lc, root->fkey_list) + { + ForeignKeyOptInfo *fkinfo = (ForeignKeyOptInfo *) lfirst(lc); + Index fact_rti = fkinfo->con_relid; + Index dim_rti = fkinfo->ref_relid; + + /* + * Expect valid RTI on both sides (otherwise should not have kept + * this foreign key. Similarly, we shouldn't get a FK with the + * same RTI on both ends. + */ + Assert(fact_rti < root->simple_rel_array_size); + Assert(dim_rti < root->simple_rel_array_size); + Assert(fact_rti != dim_rti); + + /* + * With duplicate foreign keys we might have already included this + * candidate dimension. + */ + if (list_member_int(fact_dims[fact_rti], dim_rti)) + continue; + + /* found a candidate dimension, stash it to the list */ + fact_dims[fact_rti] = lappend_int(fact_dims[fact_rti], dim_rti); + } + + /* + * For each candidate fact, validate which dimensions meet the other + * requirements, and build a StarJoinClusterInfo if at least two + * dimensions qualify. + */ + for (f = 1; f < nrels; f++) + { + RelOptInfo *fact_rel; + Relids dim_relids = NULL; + int ndims = 0; + ListCell *lc2; + StarJoinClusterInfo *cluster; + + /* + * Can't be a fact if there are no potential dimensions (we need + * at least two for a starjoin optimization to matter). + */ + if (list_length(fact_dims[f]) < 2) + continue; + + /* has to be a baserel (how else could it be, if it has a FK?) */ + fact_rel = root->simple_rel_array[f]; + if (fact_rel == NULL || fact_rel->reloptkind != RELOPT_BASEREL) + continue; + + /* count dimensions matching the requirements */ + foreach(lc2, fact_dims[f]) + { + Index d = (Index) lfirst_int(lc2); + RelOptInfo *dim_rel = root->simple_rel_array[d]; + ForeignKeyOptInfo *fkinfo = NULL; + ListCell *lc3; + bool ok = true; + int colno; + int r; + + /* has to be a baserel (it's referenced by a FK, so?) */ + if (dim_rel == NULL || dim_rel->reloptkind != RELOPT_BASEREL) + continue; + + /* No local restriction quals on the dimension. */ + if (dim_rel->baserestrictinfo != NIL) + continue; + + /* No lateral references in or out. */ + if (!bms_is_empty(dim_rel->lateral_relids)) + continue; + if (!bms_is_empty(dim_rel->lateral_referencers)) + continue; + + /* Find the matching FK so we can check NOT NULL on conkey cols */ + foreach(lc3, root->fkey_list) + { + ForeignKeyOptInfo *fki = (ForeignKeyOptInfo *) lfirst(lc3); + + if ((fki->con_relid == f) && (fki->ref_relid == d)) + { + fkinfo = fki; + break; + } + } + + /* we should have found a FK, that's how we got the array */ + Assert(fkinfo != NULL); + + /* + * Make sure all columns on the fact side are NOT NULL. We + * know the FK is fully matched by the join, so use that. + */ + for (colno = 0; colno < fkinfo->nkeys; colno++) + { + if (!bms_is_member(fkinfo->conkey[colno], + fact_rel->notnullattnums)) + { + ok = false; + break; + } + } + if (!ok) + continue; + + /* + * Should not be involved in any SpecialJoinInfo (for now the + * starjoin is done only for inner joins). + * + * XXX We could relax this, and allow left joins, as long as the + * target has a unique constraint. + */ + foreach(lc3, root->join_info_list) + { + SpecialJoinInfo *sjinfo = (SpecialJoinInfo *) lfirst(lc3); + + if (bms_is_member(d, sjinfo->min_lefthand) || + bms_is_member(d, sjinfo->min_righthand) || + bms_is_member(d, sjinfo->syn_lefthand) || + bms_is_member(d, sjinfo->syn_righthand)) + { + ok = false; + break; + } + } + if (!ok) + continue; + + /* Not constrained by any PlaceHolderInfo. */ + foreach(lc3, root->placeholder_list) + { + PlaceHolderInfo *phi = (PlaceHolderInfo *) lfirst(lc3); + + if (bms_is_member((int) d, phi->ph_eval_at) || + bms_is_member((int) d, phi->ph_lateral) || + bms_is_member((int) d, phi->ph_needed)) + { + ok = false; + break; + } + } + if (!ok) + continue; + + /* + * The dimension must have no relevant join clause to any rel + * other than the fact. have_relevant_joinclause() also covers + * equivalence-class joins. + */ + for (r = 1; r < nrels; r++) + { + RelOptInfo *other_rel; + + if (r == (int) d || r == f) + continue; + other_rel = root->simple_rel_array[r]; + if (other_rel == NULL || + other_rel->reloptkind != RELOPT_BASEREL) + continue; + if (have_relevant_joinclause(root, dim_rel, other_rel)) + { + ok = false; + break; + } + } + if (!ok) + continue; + + /* Qualifies as a dimension. */ + dim_relids = bms_add_member(dim_relids, (int) d); + ndims++; + } + + /* we need at least 2 dimensions for this optimization to matter */ + if (ndims < 2) + { + bms_free(dim_relids); + continue; + } + + /* remember this cluster */ + cluster = makeNode(StarJoinClusterInfo); + cluster->fact_relid = (Index) f; + cluster->dim_relids = dim_relids; + + root->starjoin_clusters = lappend(root->starjoin_clusters, cluster); + } + + pfree(fact_dims); +} diff --git a/src/backend/optimizer/plan/planmain.c b/src/backend/optimizer/plan/planmain.c index 02495e22e24..5734aecfd98 100644 --- a/src/backend/optimizer/plan/planmain.c +++ b/src/backend/optimizer/plan/planmain.c @@ -80,6 +80,7 @@ query_planner(PlannerInfo *root, root->group_expr_list = NIL; root->tlist_vars = NIL; root->fkey_list = NIL; + root->starjoin_clusters = NIL; root->initial_rels = NIL; /* @@ -262,6 +263,14 @@ query_planner(PlannerInfo *root, */ match_foreign_keys_to_quals(root); + /* + * Detect star-shaped sub-joins so that join_search can skip enumerating + * equivalent join orderings. Must be done after foreign keys are + * matched to quals and after lateral, PHV and SpecialJoinInfo info is + * available. + */ + generate_starjoin_clusters(root); + /* * Look for join OR clauses that we can extract single-relation * restriction OR clauses from. diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat index afaa058b046..f175611a6d9 100644 --- a/src/backend/utils/misc/guc_parameters.dat +++ b/src/backend/utils/misc/guc_parameters.dat @@ -1034,6 +1034,14 @@ boot_val => 'true', }, +{ name => 'enable_starjoin_ordering', type => 'bool', context => 'PGC_USERSET', group => 'QUERY_TUNING_METHOD', + short_desc => 'Enables collapsing of redundant join orderings in star-shaped queries.', + long_desc => 'When enabled, the planner detects star joins (a fact table joined to multiple dimension tables via foreign keys referencing unique keys, with NOT NULL FK columns and no dimension-side restrictions) and avoids enumerating equivalent join orderings, since they have identical cost.', + flags => 'GUC_EXPLAIN', + variable => 'enable_starjoin_ordering', + boot_val => 'true', +}, + { name => 'enable_tidscan', type => 'bool', context => 'PGC_USERSET', group => 'QUERY_TUNING_METHOD', short_desc => 'Enables the planner\'s use of TID scan plans.', flags => 'GUC_EXPLAIN', diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample index ac38cddaaf9..9e8812b64f5 100644 --- a/src/backend/utils/misc/postgresql.conf.sample +++ b/src/backend/utils/misc/postgresql.conf.sample @@ -448,6 +448,7 @@ #enable_distinct_reordering = on #enable_self_join_elimination = on #enable_eager_aggregate = on +#enable_starjoin_ordering = on # - Planner Cost Constants - diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h index 27a2c6815b7..4e39c906ccd 100644 --- a/src/include/nodes/pathnodes.h +++ b/src/include/nodes/pathnodes.h @@ -512,6 +512,14 @@ struct PlannerInfo /* list of ForeignKeyOptInfos */ List *fkey_list; + /* + * List of StarJoinClusterInfo entries describing detected star-shaped + * sub-joins (a fact table joined to multiple FK-unique dimensions). + * Used by join_search to skip enumeration of equivalent join orderings. + * Populated by identify_star_join_clusters(). Not serialized. + */ + List *starjoin_clusters pg_node_attr(read_write_ignore); + /* desired pathkeys for query_planner() */ List *query_pathkeys; @@ -1500,6 +1508,34 @@ typedef struct ForeignKeyOptInfo List *rinfos[INDEX_MAX_KEYS]; } ForeignKeyOptInfo; +/* + * StarJoinClusterInfo + * Description of a star-shaped sub-join detected by the planner. + * + * A "star cluster" consists of a single "fact" base relation joined to a + * set of "dimension" base relations. For the cluster to be recognized, + * each dimension must be joined to the fact via an equality condition that + * matches a foreign key on the fact referencing the dimension. The columns + * on the fact must all be NOT NULL, and the dimension must have no other + * join clauses, no local restriction quals, no participation in special + * joins or lateral references, etc. (see generate_starjoin_cluster for + * the full list of preconditions). + * + * When all of these conditions hold, every join ordering that has the fact + * as the driver produces exactly the same row count and the same per-step + * cost. join_search can therefore consider just one canonical ordering of + * the cluster without compromising plan quality. + * + * The bimap of dimensions determines the canonical join order. + */ +typedef struct StarJoinClusterInfo +{ + NodeTag type; + Index fact_relid; /* index of the fact relation */ + Relids dim_relids; /* indexes of the dimension relations */ +} StarJoinClusterInfo; + + /* * StatisticExtInfo * Information about extended statistics for planning/optimization diff --git a/src/include/optimizer/cost.h b/src/include/optimizer/cost.h index f2fd5d31507..766aba773ef 100644 --- a/src/include/optimizer/cost.h +++ b/src/include/optimizer/cost.h @@ -70,6 +70,7 @@ extern PGDLLIMPORT bool enable_parallel_hash; extern PGDLLIMPORT bool enable_partition_pruning; extern PGDLLIMPORT bool enable_presorted_aggregate; extern PGDLLIMPORT bool enable_async_append; +extern PGDLLIMPORT bool enable_starjoin_ordering; extern PGDLLIMPORT int constraint_exclusion; extern double index_pages_fetched(double tuples_fetched, BlockNumber pages, diff --git a/src/include/optimizer/planmain.h b/src/include/optimizer/planmain.h index 71c043a25e8..1c8e9ef038a 100644 --- a/src/include/optimizer/planmain.h +++ b/src/include/optimizer/planmain.h @@ -104,6 +104,7 @@ extern RestrictInfo *build_implied_join_equality(PlannerInfo *root, Index security_level); extern void rebuild_joinclause_attr_needed(PlannerInfo *root); extern void match_foreign_keys_to_quals(PlannerInfo *root); +extern void generate_starjoin_clusters(PlannerInfo *root); /* * prototypes for plan/analyzejoins.c diff --git a/src/test/modules/unsafe_tests/Makefile b/src/test/modules/unsafe_tests/Makefile index a85c854392d..dcfac290eaa 100644 --- a/src/test/modules/unsafe_tests/Makefile +++ b/src/test/modules/unsafe_tests/Makefile @@ -1,6 +1,6 @@ # src/test/modules/unsafe_tests/Makefile -REGRESS = rolenames setconfig alter_system_table guc_privs +REGRESS = setconfig alter_system_table guc_privs REGRESS_OPTS = \ --create-role=regress_authenticated_user_db_sr \ --create-role=regress_authenticated_user_db_ssa \ diff --git a/src/test/regress/expected/join.out b/src/test/regress/expected/join.out index 78bf022f7b4..8b62f343200 100644 --- a/src/test/regress/expected/join.out +++ b/src/test/regress/expected/join.out @@ -10143,3 +10143,60 @@ SELECT COUNT(*) FROM onek t1 LEFT JOIN tenk1 t2 19000 (1 row) +-- +-- Star-join collapse: FK-unique star clusters get a canonical join order +-- +CREATE TABLE sj_d1 (id int PRIMARY KEY, v text); +CREATE TABLE sj_d2 (id int PRIMARY KEY, v text); +CREATE TABLE sj_d3 (id int PRIMARY KEY, v text); +CREATE TABLE sj_f ( + id int PRIMARY KEY, + d1 int NOT NULL REFERENCES sj_d1, + d2 int NOT NULL REFERENCES sj_d2, + d3 int NOT NULL REFERENCES sj_d3 +); +ANALYZE sj_f, sj_d1, sj_d2, sj_d3; +-- With the collapse enabled (default), the resulting plan must be identical +-- to the plan produced with the optimization disabled. +SET enable_starjoin_ordering = on; +EXPLAIN (COSTS OFF) +SELECT * FROM sj_f + JOIN sj_d1 ON sj_f.d1 = sj_d1.id + JOIN sj_d2 ON sj_f.d2 = sj_d2.id + JOIN sj_d3 ON sj_f.d3 = sj_d3.id; + QUERY PLAN +------------------------------------------------- + Nested Loop + Join Filter: (sj_d3.id = sj_f.d3) + -> Nested Loop + Join Filter: (sj_d2.id = sj_f.d2) + -> Nested Loop + Join Filter: (sj_d1.id = sj_f.d1) + -> Seq Scan on sj_f + -> Seq Scan on sj_d1 + -> Seq Scan on sj_d2 + -> Seq Scan on sj_d3 +(10 rows) + +SET enable_starjoin_ordering = off; +EXPLAIN (COSTS OFF) +SELECT * FROM sj_f + JOIN sj_d1 ON sj_f.d1 = sj_d1.id + JOIN sj_d2 ON sj_f.d2 = sj_d2.id + JOIN sj_d3 ON sj_f.d3 = sj_d3.id; + QUERY PLAN +------------------------------------------------- + Nested Loop + Join Filter: (sj_d3.id = sj_f.d3) + -> Nested Loop + Join Filter: (sj_d2.id = sj_f.d2) + -> Nested Loop + Join Filter: (sj_d1.id = sj_f.d1) + -> Seq Scan on sj_f + -> Seq Scan on sj_d1 + -> Seq Scan on sj_d2 + -> Seq Scan on sj_d3 +(10 rows) + +RESET enable_starjoin_ordering; +DROP TABLE sj_f, sj_d1, sj_d2, sj_d3; diff --git a/src/test/regress/expected/sysviews.out b/src/test/regress/expected/sysviews.out index 132b56a5864..ca6c1acd00c 100644 --- a/src/test/regress/expected/sysviews.out +++ b/src/test/regress/expected/sysviews.out @@ -179,8 +179,9 @@ select name, setting from pg_settings where name like 'enable%'; enable_self_join_elimination | on enable_seqscan | on enable_sort | on + enable_starjoin_ordering | on enable_tidscan | on -(25 rows) +(26 rows) -- There are always wait event descriptions for various types. InjectionPoint -- may be present or absent, depending on history since last postmaster start. diff --git a/src/test/regress/sql/join.sql b/src/test/regress/sql/join.sql index fae19113cef..ade308aca56 100644 --- a/src/test/regress/sql/join.sql +++ b/src/test/regress/sql/join.sql @@ -3891,3 +3891,36 @@ SELECT COUNT(*) FROM onek t1 LEFT JOIN tenk1 t2 ON (t2.thousand = t1.tenthous OR t2.thousand = t1.thousand); SELECT COUNT(*) FROM onek t1 LEFT JOIN tenk1 t2 ON (t2.thousand = t1.tenthous OR t2.thousand = t1.thousand); + +-- +-- Star-join collapse: FK-unique star clusters get a canonical join order +-- +CREATE TABLE sj_d1 (id int PRIMARY KEY, v text); +CREATE TABLE sj_d2 (id int PRIMARY KEY, v text); +CREATE TABLE sj_d3 (id int PRIMARY KEY, v text); +CREATE TABLE sj_f ( + id int PRIMARY KEY, + d1 int NOT NULL REFERENCES sj_d1, + d2 int NOT NULL REFERENCES sj_d2, + d3 int NOT NULL REFERENCES sj_d3 +); +ANALYZE sj_f, sj_d1, sj_d2, sj_d3; + +-- With the collapse enabled (default), the resulting plan must be identical +-- to the plan produced with the optimization disabled. +SET enable_starjoin_ordering = on; +EXPLAIN (COSTS OFF) +SELECT * FROM sj_f + JOIN sj_d1 ON sj_f.d1 = sj_d1.id + JOIN sj_d2 ON sj_f.d2 = sj_d2.id + JOIN sj_d3 ON sj_f.d3 = sj_d3.id; + +SET enable_starjoin_ordering = off; +EXPLAIN (COSTS OFF) +SELECT * FROM sj_f + JOIN sj_d1 ON sj_f.d1 = sj_d1.id + JOIN sj_d2 ON sj_f.d2 = sj_d2.id + JOIN sj_d3 ON sj_f.d3 = sj_d3.id; + +RESET enable_starjoin_ordering; +DROP TABLE sj_f, sj_d1, sj_d2, sj_d3;