From a5239d7d62091c81550355e14784f7003347e714 Mon Sep 17 00:00:00 2001 From: Rui Zhao Date: Thu, 30 Jul 2026 23:04:14 +0800 Subject: [PATCH 2/2] Derive the non-nullable Vars once per jointree node, not once per NOT IN sublink_testexpr_is_not_nullable() ran flatten_join_alias_vars() over the whole qual list, and then find_nonnullable_vars() over the result, for every NOT IN it was asked about. The qual list is the entire WHERE clause of the node, sublinks and their subqueries included, and flatten_join_alias_vars() copies all of it, so a WHERE clause with N such NOT INs did O(N^2) work -- all of it wasted when the proof fails, as it must whenever the sub-select's output columns are nullable. Pass the quals in a SafeQuals struct instead, and derive the set of Vars they force non-null on first use, so every SubLink at the same jointree node shares one derivation. Also test the sub-select's output columns before the outer expressions, since that is only a scan of its target list. With 320 NOT INs on nullable columns in one WHERE clause, planning goes from 135ms back to 64ms, against 61ms on master. Add a test for the RIGHT JOIN mirror of the ON-qual case, which had none. --- src/backend/optimizer/plan/subselect.c | 60 +++++++++--------- src/backend/optimizer/prep/prepjointree.c | 74 +++++++++++++++-------- src/include/optimizer/subselect.h | 16 ++++- src/test/regress/expected/subselect.out | 18 ++++++ src/test/regress/sql/subselect.sql | 6 ++ 5 files changed, 119 insertions(+), 55 deletions(-) diff --git a/src/backend/optimizer/plan/subselect.c b/src/backend/optimizer/plan/subselect.c index 94b6a2d8f6..38e06f2593 100644 --- a/src/backend/optimizer/plan/subselect.c +++ b/src/backend/optimizer/plan/subselect.c @@ -93,7 +93,7 @@ static bool contain_outer_selfref_walker(Node *node, Index *depth); static void inline_cte(PlannerInfo *root, CommonTableExpr *cte); static bool inline_cte_walker(Node *node, inline_cte_walker_context *context); static bool sublink_testexpr_is_not_nullable(PlannerInfo *root, SubLink *sublink, - List *nonnullable_quals); + SafeQuals *safe_quals); static bool simplify_EXISTS_query(PlannerInfo *root, Query *query); static Query *convert_EXISTS_to_ANY(PlannerInfo *root, Query *subselect, Node **testexpr, List **paramIds); @@ -1320,10 +1320,10 @@ convert_VALUES_to_ANY(PlannerInfo *root, Node *testexpr, Query *values) * The conversion must fail if the converted qual would reference any but * these parent-query relids. * - * nonnullable_quals is a list of qual clauses that are guaranteed to filter - * the rows on which the SubLink is evaluated. We only need it for the - * under_not case, where it may help prove that the left-hand expressions are - * non-nullable. It may be NIL, in which case no such proof is attempted. + * safe_quals describes the qual clauses that are guaranteed to filter the rows + * on which the SubLink is evaluated. We only need it for the under_not case, + * where it may help prove that the left-hand expressions are non-nullable. It + * may be NULL, in which case no such proof is attempted. * * On success, the returned JoinExpr has larg = NULL and rarg = the jointree * item representing the pulled-up subquery. The caller must set larg to @@ -1347,7 +1347,7 @@ convert_VALUES_to_ANY(PlannerInfo *root, Node *testexpr, Query *values) JoinExpr * convert_ANY_sublink_to_join(PlannerInfo *root, SubLink *sublink, bool under_not, Relids available_rels, - List *nonnullable_quals) + SafeQuals *safe_quals) { JoinExpr *result; Query *parse = root->parse; @@ -1372,10 +1372,14 @@ convert_ANY_sublink_to_join(PlannerInfo *root, SubLink *sublink, * output columns can be NULL, and further that the operator itself cannot * return NULL for non-null inputs, then the logic is identical and it's * safe to convert NOT IN to an anti-join. + * + * Test the sub-select's output columns first: that's just a scan of its + * target list, whereas proving the outer expressions non-nullable may have + * to analyze the whole qual list. */ if (under_not && - (!sublink_testexpr_is_not_nullable(root, sublink, nonnullable_quals) || - !query_outputs_are_not_nullable(subselect))) + (!query_outputs_are_not_nullable(subselect) || + !sublink_testexpr_is_not_nullable(root, sublink, safe_quals))) return NULL; /* @@ -1481,12 +1485,13 @@ convert_ANY_sublink_to_join(PlannerInfo *root, SubLink *sublink, * * An outer expression is provably non-nullable if it is a column with a NOT * NULL constraint, or more generally any expression that expr_is_nonnullable - * accepts. In addition, a plain Var can be proven non-nullable if some clause - * in nonnullable_quals forces it non-null (for example "x IS NOT NULL" or a - * strict comparison on x). Those quals were collected by the caller from the - * jointree at or below the point where this NOT IN is evaluated, and are - * guaranteed to remove any NULL-valued rows before they can affect the result - * of the anti-join, so the NOT IN to anti-join conversion stays valid. + * accepts. In addition, a plain Var of the current query level can be proven + * non-nullable if some clause in safe_quals forces it non-null (for example + * "x IS NOT NULL" or a strict comparison on x). Those quals were collected by + * the caller from the jointree at or below the point where this NOT IN is + * evaluated, and are guaranteed to remove any NULL-valued rows before they can + * affect the result of the anti-join, so the NOT IN to anti-join conversion + * stays valid. * * We handle the three standard parser representations for ANY sublinks: a * single OpExpr for single-column comparisons, a BoolExpr containing a list of @@ -1500,12 +1505,10 @@ convert_ANY_sublink_to_join(PlannerInfo *root, SubLink *sublink, */ static bool sublink_testexpr_is_not_nullable(PlannerInfo *root, SubLink *sublink, - List *nonnullable_quals) + SafeQuals *safe_quals) { Node *testexpr = sublink->testexpr; List *outer_exprs = NIL; - List *nonnullable_vars = NIL; - bool computed_nonnullable_vars = false; /* Punt if sublink is not in the expected format */ if (sublink->subLinkType != ANY_SUBLINK || testexpr == NULL) @@ -1596,10 +1599,10 @@ sublink_testexpr_is_not_nullable(PlannerInfo *root, SubLink *sublink, /* * For a plain Var, even if that didn't work, we can still prove it * non-nullable if find_nonnullable_vars can find a "var IS NOT NULL" - * or similarly strict condition among nonnullable_quals. Those are - * the quals the caller determined are guaranteed to filter the rows - * on which this SubLink is evaluated. Compute the list of Vars they - * force non-null if we didn't already. + * or similarly strict condition among the safe quals. Those are the + * quals the caller determined are guaranteed to filter the rows on + * which this SubLink is evaluated. Derive the set of Vars they force + * non-null if that hasn't been done yet for this jointree node. * * find_nonnullable_vars only reports Vars of the current query level, * and identifies them by varno and varattno alone, so an upper-level @@ -1610,25 +1613,26 @@ sublink_testexpr_is_not_nullable(PlannerInfo *root, SubLink *sublink, * just as the outer expressions were above, so that the Vars match * up. */ - if (nonnullable_quals != NIL && IsA(expr, Var) && - ((Var *) expr)->varlevelsup == 0) + if (safe_quals != NULL && safe_quals->quals != NIL && + IsA(expr, Var) && ((Var *) expr)->varlevelsup == 0) { Var *var = (Var *) expr; - if (!computed_nonnullable_vars) + if (!safe_quals->computed) { List *flat_quals; flat_quals = (List *) flatten_join_alias_vars(root, root->parse, - (Node *) nonnullable_quals); - nonnullable_vars = find_nonnullable_vars((Node *) flat_quals); - computed_nonnullable_vars = true; + (Node *) safe_quals->quals); + safe_quals->nonnullable_vars = + find_nonnullable_vars((Node *) flat_quals); + safe_quals->computed = true; } if (mbms_is_member(var->varno, var->varattno - FirstLowInvalidHeapAttributeNumber, - nonnullable_vars)) + safe_quals->nonnullable_vars)) continue; } diff --git a/src/backend/optimizer/prep/prepjointree.c b/src/backend/optimizer/prep/prepjointree.c index d2c132fcbe..c52e4bd6df 100644 --- a/src/backend/optimizer/prep/prepjointree.c +++ b/src/backend/optimizer/prep/prepjointree.c @@ -108,12 +108,13 @@ typedef struct reduce_outer_joins_partial_state static Query *expand_virtual_generated_columns(PlannerInfo *root, Query *parse, RangeTblEntry *rte, int rt_index, Relation relation); +static void init_safe_quals(SafeQuals *sq, List *quals); static Node *pull_up_sublinks_jointree_recurse(PlannerInfo *root, Node *jtnode, Relids *relids, List **safe_quals); static Node *pull_up_sublinks_qual_recurse(PlannerInfo *root, Node *node, Node **jtlink1, Relids available_rels1, Node **jtlink2, Relids available_rels2, - List *nonnullable_quals); + SafeQuals *safe_quals); static Node *pull_up_subqueries_recurse(PlannerInfo *root, Node *jtnode, JoinExpr *lowest_outer_join, AppendRelInfo *containing_appendrel); @@ -691,6 +692,21 @@ pull_up_sublinks(PlannerInfo *root) root->parse->jointree = makeFromExpr(list_make1(jtnode), NULL); } +/* + * Set up a SafeQuals for the given qual list. + * + * The set of Vars the quals force non-null is derived lazily, on the first + * NOT IN that actually needs it, and then shared by every SubLink at this + * jointree node. + */ +static void +init_safe_quals(SafeQuals *sq, List *quals) +{ + sq->quals = quals; + sq->nonnullable_vars = NIL; + sq->computed = false; +} + /* * Recurse through jointree nodes for pull_up_sublinks() * @@ -727,6 +743,7 @@ pull_up_sublinks_jointree_recurse(PlannerInfo *root, Node *jtnode, List *newfromlist = NIL; Relids frelids = NULL; List *fsafequals = NIL; + SafeQuals fsq; FromExpr *newf; Node *jtlink; ListCell *l; @@ -758,10 +775,11 @@ pull_up_sublinks_jointree_recurse(PlannerInfo *root, Node *jtnode, /* Set up a link representing the rebuilt jointree */ jtlink = (Node *) newf; /* Now process qual --- all children are available for use */ + init_safe_quals(&fsq, fsafequals); newf->quals = pull_up_sublinks_qual_recurse(root, f->quals, &jtlink, frelids, NULL, NULL, - fsafequals); + &fsq); /* * Note that the result will be either newf, or a stack of JoinExprs @@ -784,6 +802,7 @@ pull_up_sublinks_jointree_recurse(PlannerInfo *root, Node *jtnode, List *leftsafequals; List *rightsafequals; List *passquals; + SafeQuals jsq; Node *jtlink; /* @@ -827,12 +846,13 @@ pull_up_sublinks_jointree_recurse(PlannerInfo *root, Node *jtnode, if (j->quals) passquals = lappend(passquals, j->quals); + init_safe_quals(&jsq, passquals); j->quals = pull_up_sublinks_qual_recurse(root, j->quals, &jtlink, bms_union(leftrelids, rightrelids), NULL, NULL, - passquals); + &jsq); *safe_quals = passquals; break; @@ -849,11 +869,12 @@ pull_up_sublinks_jointree_recurse(PlannerInfo *root, Node *jtnode, if (j->quals) passquals = lappend(passquals, j->quals); + init_safe_quals(&jsq, passquals); j->quals = pull_up_sublinks_qual_recurse(root, j->quals, &j->rarg, rightrelids, NULL, NULL, - passquals); + &jsq); *safe_quals = leftsafequals; break; @@ -867,11 +888,12 @@ pull_up_sublinks_jointree_recurse(PlannerInfo *root, Node *jtnode, if (j->quals) passquals = lappend(passquals, j->quals); + init_safe_quals(&jsq, passquals); j->quals = pull_up_sublinks_qual_recurse(root, j->quals, &j->larg, leftrelids, NULL, NULL, - passquals); + &jsq); *safe_quals = rightsafequals; break; @@ -913,13 +935,13 @@ pull_up_sublinks_jointree_recurse(PlannerInfo *root, Node *jtnode, * and/or jtlink2 in the order we encounter them. We rely on subsequent * optimization to rearrange the stack if appropriate. * - * nonnullable_quals is the qual list we hand to convert_ANY_sublink_to_join - * when trying to convert a NOT IN against available_rels1; see there for what - * it means. These quals are known to apply to the rows at this node, so we - * pass them down unchanged through AND clauses (whose arms share that - * context), but pass NIL when recursing into a just-pulled-up SubLink's quals, - * or when converting against available_rels2, since those are evaluated on - * different rows that the quals say nothing about. + * safe_quals describes the quals we hand to convert_ANY_sublink_to_join when + * trying to convert a NOT IN against available_rels1; see there for what it + * means. These quals are known to apply to the rows at this node, so we pass + * them down unchanged through AND clauses (whose arms share that context), but + * pass NULL when recursing into a just-pulled-up SubLink's quals, or when + * converting against available_rels2, since those are evaluated on different + * rows that the quals say nothing about. * * Returns the replacement qual node, or NULL if the qual should be removed. */ @@ -927,7 +949,7 @@ static Node * pull_up_sublinks_qual_recurse(PlannerInfo *root, Node *node, Node **jtlink1, Relids available_rels1, Node **jtlink2, Relids available_rels2, - List *nonnullable_quals) + SafeQuals *safe_quals) { if (node == NULL) return NULL; @@ -955,7 +977,7 @@ pull_up_sublinks_qual_recurse(PlannerInfo *root, Node *node, } if ((j = convert_ANY_sublink_to_join(root, sublink, false, - available_rels1, NIL)) != NULL) + available_rels1, NULL)) != NULL) { /* Yes; insert the new join node into the join tree */ j->larg = *jtlink1; @@ -977,13 +999,13 @@ pull_up_sublinks_qual_recurse(PlannerInfo *root, Node *node, available_rels1, &j->rarg, child_rels, - NIL); + NULL); /* Return NULL representing constant TRUE */ return NULL; } if (available_rels2 != NULL && (j = convert_ANY_sublink_to_join(root, sublink, false, - available_rels2, NIL)) != NULL) + available_rels2, NULL)) != NULL) { /* Yes; insert the new join node into the join tree */ j->larg = *jtlink2; @@ -1005,7 +1027,7 @@ pull_up_sublinks_qual_recurse(PlannerInfo *root, Node *node, available_rels2, &j->rarg, child_rels, - NIL); + NULL); /* Return NULL representing constant TRUE */ return NULL; } @@ -1035,7 +1057,7 @@ pull_up_sublinks_qual_recurse(PlannerInfo *root, Node *node, available_rels1, &j->rarg, child_rels, - NIL); + NULL); /* Return NULL representing constant TRUE */ return NULL; } @@ -1063,7 +1085,7 @@ pull_up_sublinks_qual_recurse(PlannerInfo *root, Node *node, available_rels2, &j->rarg, child_rels, - NIL); + NULL); /* Return NULL representing constant TRUE */ return NULL; } @@ -1085,7 +1107,7 @@ pull_up_sublinks_qual_recurse(PlannerInfo *root, Node *node, { if ((j = convert_ANY_sublink_to_join(root, sublink, true, available_rels1, - nonnullable_quals)) != NULL) + safe_quals)) != NULL) { /* Yes; insert the new join node into the join tree */ j->larg = *jtlink1; @@ -1107,14 +1129,14 @@ pull_up_sublinks_qual_recurse(PlannerInfo *root, Node *node, &j->rarg, child_rels, NULL, NULL, - NIL); + NULL); /* Return NULL representing constant TRUE */ return NULL; } if (available_rels2 != NULL && (j = convert_ANY_sublink_to_join(root, sublink, true, available_rels2, - NIL)) != NULL) + NULL)) != NULL) { /* Yes; insert the new join node into the join tree */ j->larg = *jtlink2; @@ -1136,7 +1158,7 @@ pull_up_sublinks_qual_recurse(PlannerInfo *root, Node *node, &j->rarg, child_rels, NULL, NULL, - NIL); + NULL); /* Return NULL representing constant TRUE */ return NULL; } @@ -1166,7 +1188,7 @@ pull_up_sublinks_qual_recurse(PlannerInfo *root, Node *node, &j->rarg, child_rels, NULL, NULL, - NIL); + NULL); /* Return NULL representing constant TRUE */ return NULL; } @@ -1194,7 +1216,7 @@ pull_up_sublinks_qual_recurse(PlannerInfo *root, Node *node, &j->rarg, child_rels, NULL, NULL, - NIL); + NULL); /* Return NULL representing constant TRUE */ return NULL; } @@ -1220,7 +1242,7 @@ pull_up_sublinks_qual_recurse(PlannerInfo *root, Node *node, available_rels1, jtlink2, available_rels2, - nonnullable_quals); + safe_quals); if (newclause) newclauses = lappend(newclauses, newclause); } diff --git a/src/include/optimizer/subselect.h b/src/include/optimizer/subselect.h index f3d4a2138b..bc7c194091 100644 --- a/src/include/optimizer/subselect.h +++ b/src/include/optimizer/subselect.h @@ -16,6 +16,20 @@ #include "nodes/pathnodes.h" #include "nodes/plannodes.h" +/* + * Quals that are guaranteed to filter the rows a SubLink is evaluated on, + * along with the set of Vars they force non-null. The latter is expensive + * to derive and most SubLinks never need it, so it's computed on first use + * and then shared by every SubLink at the same jointree node. + */ +typedef struct SafeQuals +{ + List *quals; /* qual clauses, implicitly ANDed */ + List *nonnullable_vars; /* multibitmapset of the Vars they force + * non-null; valid only if computed */ + bool computed; /* has nonnullable_vars been derived yet? */ +} SafeQuals; + extern void SS_process_ctes(PlannerInfo *root); extern ScalarArrayOpExpr *convert_VALUES_to_ANY(PlannerInfo *root, Node *testexpr, @@ -24,7 +38,7 @@ extern JoinExpr *convert_ANY_sublink_to_join(PlannerInfo *root, SubLink *sublink, bool under_not, Relids available_rels, - List *nonnullable_quals); + SafeQuals *safe_quals); extern JoinExpr *convert_EXISTS_sublink_to_join(PlannerInfo *root, SubLink *sublink, bool under_not, diff --git a/src/test/regress/expected/subselect.out b/src/test/regress/expected/subselect.out index bfba1d532f..87301dc568 100644 --- a/src/test/regress/expected/subselect.out +++ b/src/test/regress/expected/subselect.out @@ -3682,6 +3682,24 @@ ON t2.val > 0 AND t2.val NOT IN (SELECT id FROM not_null_tab); -> Seq Scan on not_null_tab (9 rows) +-- Same, with the sides of the outer join swapped +EXPLAIN (COSTS OFF) +SELECT * FROM null_tab t2 +RIGHT JOIN not_null_tab t1 +ON t2.val > 0 AND t2.val NOT IN (SELECT id FROM not_null_tab); + QUERY PLAN +----------------------------------------------------- + Nested Loop Left Join + -> Seq Scan on not_null_tab t1 + -> Materialize + -> Hash Anti Join + Hash Cond: (t2.val = not_null_tab.id) + -> Seq Scan on null_tab t2 + Filter: (val > 0) + -> Hash + -> Seq Scan on not_null_tab +(9 rows) + -- ANTI JOIN: outer side is forced non-nullable by the inner join's ON qual EXPLAIN (COSTS OFF) SELECT * FROM null_tab t1 LEFT JOIN diff --git a/src/test/regress/sql/subselect.sql b/src/test/regress/sql/subselect.sql index 4d00786ee3..e6327ffe5f 100644 --- a/src/test/regress/sql/subselect.sql +++ b/src/test/regress/sql/subselect.sql @@ -1617,6 +1617,12 @@ SELECT * FROM not_null_tab t1 LEFT JOIN null_tab t2 ON t2.val > 0 AND t2.val NOT IN (SELECT id FROM not_null_tab); +-- Same, with the sides of the outer join swapped +EXPLAIN (COSTS OFF) +SELECT * FROM null_tab t2 +RIGHT JOIN not_null_tab t1 +ON t2.val > 0 AND t2.val NOT IN (SELECT id FROM not_null_tab); + -- ANTI JOIN: outer side is forced non-nullable by the inner join's ON qual EXPLAIN (COSTS OFF) SELECT * FROM null_tab t1 LEFT JOIN -- 2.43.7