Re: Prove a NOT IN's left-hand expressions non-nullable from quals

From: Rui Zhao <zhaorui126(at)gmail(dot)com>
To: Richard Guo <guofenglinux(at)gmail(dot)com>
Cc: Ayush Tiwari <ayushtiwari(dot)slg01(at)gmail(dot)com>, qiuwenhuifx(at)gmail(dot)com, pgsql-hackers <pgsql-hackers(at)lists(dot)postgresql(dot)org>
Subject: Re: Prove a NOT IN's left-hand expressions non-nullable from quals
Date: 2026-08-17 03:30:10
Message-ID: CAHWVJhEMtRm4qRLzR6c6K4mbFRA_GJnjUg+EMoQ-+eDc-nZCTw@mail.gmail.com
Views: Whole Thread | Raw Message | Download mbox | Resend email
Thread:
Lists: pgsql-hackers

Hi Richard,

v2 applies to master (d29d469bece), builds warning-free and make check is
green, and both of last round's items are fixed.

I diffed v2's results against master's on generated 2-3 rel jointrees, 1377
of which convert where master doesn't, and on partitioned and inheritance
parents, views, UNION ALL, CTEs, UPDATE and DELETE. No differences in what
I ran. Three things, none of them about correctness.

1) One shape the sharing doesn't reach. A node's SafeQualsInfo carries every
safe qual at or below it:

passquals = list_concat(leftsafequals, rightsafequals);
if (j->quals)
passquals = lappend(passquals, j->quals);

and the derivation flattens that whole list, once per node:

flat_quals = (List *)
flatten_join_alias_vars(root, root->parse, (Node *) sqinfo->quals);
sqinfo->nonnullable_vars = find_nonnullable_vars((Node *) flat_quals);
sqinfo->computed = true;

For example, with N joins each carrying one NOT IN:

SELECT count(*) FROM t
JOIN j1 ON (t.c1 NOT IN (SELECT s.d1 FROM s
WHERE s.filler = 0 OR s.filler = 1 OR ...))
JOIN j2 ON (t.c2 NOT IN (SELECT s.d2 FROM s
WHERE s.filler = 0 OR s.filler = 1 OR ...))
...

t's columns are nullable and no qual proves otherwise, so nothing is converted;
the s.filler terms are padding, 400 per sub-select, to make each copied qual
big enough to measure. EXPLAIN (MEMORY) used, in kB:

N master v2 v2+0001 v2-master v2+0001-master
1 751 841 841 90 90
4 2989 3889 3350 900 361
6 4653 6543 5194 1890 541
8 6402 9641 7122 3239 720
10 7886 12835 8786 4949 900

One flattened copy of one such ON clause -- one sub-select with its 400
s.filler terms -- is 90 kB, which the N=1 row measures on its own; the last
two columns are then 90 kB * N(N+1)/2 and 90 kB * N, to within a kB. With a
couple of small NOT INs this is negligible; the concern is that it grows as
N(N+1)/2 in their number and linearly in their size, and none of it is freed
until planning is done.

0001 attached keeps each node's own qual and a list of its children instead:

typedef struct SafeQualsInfo
{
- List *quals; /* qual clauses, with implicit-AND semantics */
+ Node *quals; /* this node's own WHERE or ON qual, or NULL */
+ List *children; /* child SafeQualsInfos whose quals apply */
List *nonnullable_vars; /* multibitmapset; valid if computed */
bool computed;
} SafeQualsInfo;

so the same place now reads

sqinfo = make_safe_quals_info(j->quals,
safe_quals_children(leftsafequals,
rightsafequals));

and derives bottom-up, cached per node. Same Vars proven, same conversions:
the 1377 plans above are identical to v2's, and make check is green.

2) The two tests run in the wrong order for the usual case:

if (under_not &&
/* flattens the whole node's clause, once per node */
(!sublink_testexpr_is_not_nullable(root, sublink, sqinfo) ||
/* flattens this sub-select's quals, once per NOT IN */
!query_outputs_are_not_nullable(subselect)))
return NULL;

With the small sub-selects most NOT INs have, the first is the more expensive
of the two. || short-circuits left to right, so it always runs first,
including when the second one was going to fail anyway. For example:

SELECT * FROM ot
WHERE ot.c1 IS NOT NULL
AND ot.c1 NOT IN (SELECT os.d1 FROM os WHERE os.filler = 0 OR ...);

os.d1 is nullable and no qual proves otherwise, so the second test fails and
this can never become an anti-join. But ot.c1 IS NOT NULL satisfies the
first, so the derivation runs in full before the second rejects it. Below is
the same measurement with the two tests swapped: N=40 such conjuncts in one
WHERE clause, sub-selects of Q quals each, EXPLAIN (MEMORY) used in kB, and no
conversion possible in any row.

Q master before swap after swap saved
2 690 822 730 92 69%
10 1204 1542 1377 165 48%
50 3709 5052 4534 518 38%
400 25689 35843 32233 3610 35%

So query_outputs_are_not_nullable is the one that should run first in almost
every query -- which is what the 0002 I posted last round did. It only loses
where the left-hand side isn't a plain Var: there the first test fails without
ever touching the quals, so v2 costs nothing over master and the swap is what
adds. The same 40 conjuncts with the left-hand sides written (ot.c1 + 0):

Q master before swap after swap cost
2 723 723 755 32
10 1238 1238 1402 164
50 3743 3743 4560 817
400 25723 25723 32258 6535

But (ot.c1 + 0) is rare -- a NOT IN's left-hand side is a column in almost
everything I've seen. The swap changes nothing observable either way: same
rows and same plans as v2 on the generated queries above, and make check is
green.

3) No test reaches this:

while (IsA(expr, RelabelType))
expr = ((RelabelType *) expr)->arg;

make check is green with those two lines removed. They aren't dead code
though:

CREATE TABLE tv (a varchar);
CREATE TABLE sv (b varchar NOT NULL);
SELECT * FROM tv WHERE a IS NOT NULL AND a NOT IN (SELECT b FROM sv);

gives a Hash Anti Join on v2, and a SubPlan with the two lines taken back
out; a domain over int behaves the same.

Thanks,
Rui

Attachment Content-Type Size
0001-Derive-the-safe-quals-non-nullable-Vars-once-per-joi.patch application/octet-stream 15.3 KB

In response to

Browse pgsql-hackers by date

  From Date Subject
Next Message wenhui qiu 2026-08-17 03:58:46 Re: Fix CPU cost of right-semi and right-anti hash joins
Previous Message Richard Guo 2026-08-17 03:26:59 Fix CPU cost of right-semi and right-anti hash joins